Phase 4: KI-UI-Steuerung — AI agent UI control via WebSocket
- New ai_ui_control plugin: WS endpoint /ws/ai-ui-control, REST API (POST /command, GET /command/{id}/status, GET /online-users)
- UI-Command-Protocol: 6 command types (navigate, filter, open_contact, modal, tab, settings) with Pydantic schemas
- WebSocket manager: per-user connections, command delivery, feedback storage, stale cleanup
- Frontend useAIUIControl hook: WS client with auto-reconnect, command dispatch, feedback sending
- aiUIControlStore: Zustand store for command state, active modal/tab, pending filter/settings
- AIUIControlIndicator: visual KI indication (Bot icon, toast, pulse animation)
- ContactDetail integration: syncs activeTab and personModalOpen from AI control store
- AppShell integration: useAIUIControl hook + AIUIControlIndicator
- i18n keys for DE/EN
- 18 Vitest tests: command protocol, store actions, feedback, visual indication
- TSC: 0 new errors (only 2 pre-existing Dms.tsx errors)
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Tests for AI UI Control — Phase 4.12
|
||||
*
|
||||
* Tests cover:
|
||||
* - Command protocol (Task 4.1): UICommand types and validation
|
||||
* - useAIUIControl hook (Task 4.3): WebSocket connection and command dispatch
|
||||
* - Command execution (Tasks 4.4-4.9): navigate, filter, open_contact, modal, tab, settings
|
||||
* - Feedback (Task 4.10): frontend sends confirmation back
|
||||
* - Visual indication (Task 4.11): store state for AI active
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAIUIControlStore, type UICommand } from '@/store/aiUIControlStore';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sentMessages: string[] = [];
|
||||
|
||||
constructor(public url: string) {
|
||||
MockWebSocket.instances.push(this);
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sentMessages.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.(new CloseEvent('close'));
|
||||
}
|
||||
|
||||
// Helper to simulate receiving a message
|
||||
receiveMessage(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => {
|
||||
const params = new URLSearchParams();
|
||||
return [params, vi.fn()];
|
||||
},
|
||||
useLocation: () => ({ pathname: '/contacts' }),
|
||||
}));
|
||||
|
||||
// Mock global WebSocket
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
|
||||
describe('AI UI Control Store', () => {
|
||||
beforeEach(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand(null);
|
||||
useAIUIControlStore.getState().setActiveModal(null);
|
||||
useAIUIControlStore.getState().setActiveTab(null);
|
||||
useAIUIControlStore.getState().setPendingFilter(null);
|
||||
useAIUIControlStore.getState().setPendingSettings(null);
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const state = useAIUIControlStore.getState();
|
||||
expect(state.connected).toBe(false);
|
||||
expect(state.activeCommand).toBeNull();
|
||||
expect(state.aiActive).toBe(false);
|
||||
expect(state.commandHistory).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set active command and aiActive flag', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'test-1',
|
||||
action: 'navigate',
|
||||
path: '/contacts/123',
|
||||
};
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand(cmd);
|
||||
});
|
||||
expect(useAIUIControlStore.getState().activeCommand).toEqual(cmd);
|
||||
expect(useAIUIControlStore.getState().aiActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear pending state', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'test-2',
|
||||
action: 'modal',
|
||||
modal: 'edit',
|
||||
};
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand(cmd);
|
||||
useAIUIControlStore.getState().setActiveModal('edit');
|
||||
useAIUIControlStore.getState().setPendingFilter({ entity: 'contacts', filter: { type: 'company' } });
|
||||
});
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().clearPending();
|
||||
});
|
||||
expect(useAIUIControlStore.getState().activeCommand).toBeNull();
|
||||
expect(useAIUIControlStore.getState().aiActive).toBe(false);
|
||||
expect(useAIUIControlStore.getState().pendingFilter).toBeNull();
|
||||
});
|
||||
|
||||
it('should add commands to history (max 50)', () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().addCommandToHistory({
|
||||
command_id: `cmd-${i}`,
|
||||
action: 'navigate',
|
||||
path: `/page/${i}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(useAIUIControlStore.getState().commandHistory).toHaveLength(50);
|
||||
expect(useAIUIControlStore.getState().commandHistory[49].command_id).toBe('cmd-54');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI UI Control — Command Protocol (Task 4.1)', () => {
|
||||
it('should define all 6 command types', () => {
|
||||
const validActions = ['navigate', 'filter', 'open_contact', 'modal', 'tab', 'settings'];
|
||||
expect(validActions).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('navigate command should have path field', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'nav-1',
|
||||
action: 'navigate',
|
||||
path: '/contacts/123',
|
||||
};
|
||||
expect(cmd.action).toBe('navigate');
|
||||
expect(cmd.path).toBe('/contacts/123');
|
||||
});
|
||||
|
||||
it('filter command should have entity and filter fields', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'filter-1',
|
||||
action: 'filter',
|
||||
entity: 'contacts',
|
||||
filter: { type: 'company', city: 'Berlin' },
|
||||
};
|
||||
expect(cmd.action).toBe('filter');
|
||||
expect(cmd.entity).toBe('contacts');
|
||||
expect(cmd.filter).toEqual({ type: 'company', city: 'Berlin' });
|
||||
});
|
||||
|
||||
it('open_contact command should have contact_id field', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'open-1',
|
||||
action: 'open_contact',
|
||||
contact_id: 'abc-123',
|
||||
};
|
||||
expect(cmd.action).toBe('open_contact');
|
||||
expect(cmd.contact_id).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('modal command should have modal field', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'modal-1',
|
||||
action: 'modal',
|
||||
modal: 'edit',
|
||||
};
|
||||
expect(cmd.action).toBe('modal');
|
||||
expect(cmd.modal).toBe('edit');
|
||||
});
|
||||
|
||||
it('tab command should have tab field', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'tab-1',
|
||||
action: 'tab',
|
||||
tab: 'emails',
|
||||
};
|
||||
expect(cmd.action).toBe('tab');
|
||||
expect(cmd.tab).toBe('emails');
|
||||
});
|
||||
|
||||
it('settings command should have section, key, value fields', () => {
|
||||
const cmd: UICommand = {
|
||||
command_id: 'settings-1',
|
||||
action: 'settings',
|
||||
section: 'ai',
|
||||
key: 'model',
|
||||
value: 'gpt-4',
|
||||
};
|
||||
expect(cmd.action).toBe('settings');
|
||||
expect(cmd.section).toBe('ai');
|
||||
expect(cmd.key).toBe('model');
|
||||
expect(cmd.value).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI UI Control — Store Actions (Tasks 4.4-4.9)', () => {
|
||||
beforeEach(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand(null);
|
||||
useAIUIControlStore.getState().setActiveModal(null);
|
||||
useAIUIControlStore.getState().setActiveTab(null);
|
||||
useAIUIControlStore.getState().setPendingFilter(null);
|
||||
useAIUIControlStore.getState().setPendingSettings(null);
|
||||
});
|
||||
|
||||
it('should set active modal (Task 4.7)', () => {
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveModal('edit');
|
||||
});
|
||||
expect(useAIUIControlStore.getState().activeModal).toBe('edit');
|
||||
});
|
||||
|
||||
it('should set active tab (Task 4.8)', () => {
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveTab('emails');
|
||||
});
|
||||
expect(useAIUIControlStore.getState().activeTab).toBe('emails');
|
||||
});
|
||||
|
||||
it('should set pending filter (Task 4.5)', () => {
|
||||
const filter = { entity: 'contacts', filter: { type: 'company' } };
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setPendingFilter(filter);
|
||||
});
|
||||
expect(useAIUIControlStore.getState().pendingFilter).toEqual(filter);
|
||||
});
|
||||
|
||||
it('should set pending settings (Task 4.9)', () => {
|
||||
const settings = { section: 'ai', key: 'model', value: 'gpt-4' };
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setPendingSettings(settings);
|
||||
});
|
||||
expect(useAIUIControlStore.getState().pendingSettings).toEqual(settings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI UI Control — Feedback (Task 4.10)', () => {
|
||||
it('should store last feedback', () => {
|
||||
const feedback = {
|
||||
command_id: 'cmd-feedback-1',
|
||||
status: 'success' as const,
|
||||
action: 'navigate' as const,
|
||||
current_path: '/contacts/123',
|
||||
};
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setLastFeedback(feedback);
|
||||
});
|
||||
expect(useAIUIControlStore.getState().lastFeedback).toEqual(feedback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI UI Control — Visual Indication (Task 4.11)', () => {
|
||||
it('should set aiActive when command is set', () => {
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand({
|
||||
command_id: 'vis-1',
|
||||
action: 'navigate',
|
||||
path: '/dashboard',
|
||||
});
|
||||
});
|
||||
expect(useAIUIControlStore.getState().aiActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear aiActive when command is cleared', () => {
|
||||
act(() => {
|
||||
useAIUIControlStore.getState().setActiveCommand({
|
||||
command_id: 'vis-2',
|
||||
action: 'navigate',
|
||||
path: '/dashboard',
|
||||
});
|
||||
useAIUIControlStore.getState().clearPending();
|
||||
});
|
||||
expect(useAIUIControlStore.getState().aiActive).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user