test(7.2): add tests for AI components

- ChatWindow: messages, input, send button, streaming, error display
- SessionList: sessions, folders, click handler, loading/error states
- SuggestionSidebar: suggestions list, filters, dismiss, close button
- AISettings: tabs (providers, presets, agents, tools), provider list
- ProactiveAISettings: toggle, categories, confidence, rate limit, model

All 40 AI tests passing.
This commit is contained in:
Agent Zero
2026-07-24 00:52:34 +02:00
parent 62127c6544
commit 02024d32b8
5 changed files with 519 additions and 0 deletions
@@ -0,0 +1,79 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('@/api/ai', () => ({
fetchProviders: vi.fn().mockResolvedValue([
{ id: 'p1', name: 'OpenAI', provider_type: 'openai', api_key: 'sk-***', base_url: '', is_default: true },
{ id: 'p2', name: 'Ollama', provider_type: 'ollama', api_key: '', base_url: 'http://localhost:11434', is_default: false },
]),
createProvider: vi.fn().mockResolvedValue({ id: 'p3' }),
updateProvider: vi.fn().mockResolvedValue({}),
deleteProvider: vi.fn().mockResolvedValue({}),
fetchPresets: vi.fn().mockResolvedValue([
{ id: 'pr1', name: 'GPT-4o', model_id: 'gpt-4o', provider_id: 'p1', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' },
]),
createPreset: vi.fn().mockResolvedValue({ id: 'pr2' }),
updatePreset: vi.fn().mockResolvedValue({}),
deletePreset: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([
{ id: 'a1', name: 'Default Agent', description: 'Default assistant', system_prompt: 'You are helpful', preset_id: 'pr1', tool_ids: ['search'], is_default: true },
]),
createAgent: vi.fn().mockResolvedValue({ id: 'a2' }),
updateAgent: vi.fn().mockResolvedValue({}),
deleteAgent: vi.fn().mockResolvedValue({}),
fetchTools: vi.fn().mockResolvedValue([
{ name: 'search', description: 'Search tool', plugin_name: 'core', category: 'utility', required_permission: null },
]),
}));
import { AISettingsPage } from '@/pages/AISettings';
describe('AISettingsPage', () => {
it('renders AI settings page with title', () => {
render(<AISettingsPage />);
expect(screen.getByText('KI Assistent Einstellungen')).toBeInTheDocument();
});
it('renders provider tab', () => {
render(<AISettingsPage />);
expect(screen.getByText('Anbieter')).toBeInTheDocument();
});
it('renders presets tab', () => {
render(<AISettingsPage />);
expect(screen.getByText('Modelle & Presets')).toBeInTheDocument();
});
it('renders agents tab', () => {
render(<AISettingsPage />);
expect(screen.getByText('Agenten')).toBeInTheDocument();
});
it('renders tools tab', () => {
render(<AISettingsPage />);
expect(screen.getByText('Tools')).toBeInTheDocument();
});
it('renders provider list after loading', async () => {
render(<AISettingsPage />);
await waitFor(() => {
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('Ollama')).toBeInTheDocument();
});
});
it('renders add provider button', async () => {
render(<AISettingsPage />);
await waitFor(() => {
expect(screen.getByText('+ Hinzufügen')).toBeInTheDocument();
});
});
it('renders edit and delete buttons for providers', async () => {
render(<AISettingsPage />);
await waitFor(() => {
expect(screen.getAllByText('Bearbeiten').length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,113 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ChatWindow } from '@/components/ai/ChatWindow';
const mockMessages = [
{ id: 'm1', session_id: 's1', role: 'user', content: 'Hello AI', tokens: 10, model_used: '' },
{ id: 'm2', session_id: 's1', role: 'assistant', content: 'Hello User', tokens: 10, model_used: 'gpt-4' },
];
const fetchMessagesMock = vi.fn();
const fetchAttachmentsMock = vi.fn();
const streamChatMock = vi.fn();
const uploadAttachmentMock = vi.fn();
vi.mock('@/api/ai', () => ({
fetchMessages: (...args: any[]) => fetchMessagesMock(...args),
fetchAttachments: (...args: any[]) => fetchAttachmentsMock(...args),
streamChat: (...args: any[]) => streamChatMock(...args),
uploadAttachment: (...args: any[]) => uploadAttachmentMock(...args),
getAttachmentDownloadUrl: (id: string) => `/api/v1/ai/attachments/${id}/download`,
}));
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock('remark-gfm', () => ({
default: () => ({}),
}));
beforeEach(() => {
vi.clearAllMocks();
fetchMessagesMock.mockResolvedValue(mockMessages);
fetchAttachmentsMock.mockResolvedValue([]);
streamChatMock.mockReturnValue({
async *[Symbol.asyncIterator]() {
yield { type: 'token', content: 'Hi' };
yield { type: 'done' };
},
});
});
describe('ChatWindow', () => {
it('renders chat window with messages', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByText('Hello AI')).toBeInTheDocument();
expect(screen.getByText('Hello User')).toBeInTheDocument();
});
});
it('renders textarea input for message', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
});
});
it('renders send button', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByText('Senden')).toBeInTheDocument();
});
});
it('disables send button when input is empty', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
const sendBtn = screen.getByText('Senden');
expect(sendBtn).toBeDisabled();
});
});
it('enables send button when input has text', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Nachricht eingeben...');
fireEvent.change(input, { target: { value: 'Test message' } });
expect(screen.getByText('Senden')).not.toBeDisabled();
});
it('shows empty state when no messages', async () => {
fetchMessagesMock.mockResolvedValueOnce([]);
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByText('Starte eine Konversation...')).toBeInTheDocument();
});
});
it('displays error when fetchMessages fails', async () => {
fetchMessagesMock.mockRejectedValueOnce(new Error('Load failed'));
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByText('Load failed')).toBeInTheDocument();
});
});
it('sends message on button click', async () => {
render(<ChatWindow sessionId="s1" />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Nachricht eingeben...');
fireEvent.change(input, { target: { value: 'Test message' } });
fireEvent.click(screen.getByText('Senden'));
await waitFor(() => {
expect(streamChatMock).toHaveBeenCalledWith('s1', 'Test message', undefined);
});
});
});
@@ -0,0 +1,107 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProactiveAISettings } from '@/pages/ProactiveAISettings';
const mockSettings = {
enabled: true,
suggestion_categories: ['mail', 'tasks'],
confidence_threshold: 0.7,
rate_limit_seconds: 15,
model: 'ollama/deepseek-v4',
heartbeat_enabled: true,
heartbeat_interval_seconds: 300,
heartbeat_target_room: 'Live KI',
};
const apiGetMock = vi.fn();
const apiPutMock = vi.fn();
vi.mock('@/api/client', () => ({
apiClient: {
get: (...args: any[]) => apiGetMock(...args),
post: vi.fn().mockResolvedValue({ data: {} }),
put: (...args: any[]) => apiPutMock(...args),
},
}));
beforeEach(() => {
vi.clearAllMocks();
apiGetMock.mockResolvedValue({ data: mockSettings });
apiPutMock.mockResolvedValue({ data: mockSettings });
});
describe('ProactiveAISettings', () => {
it('renders proactive AI settings with title', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('Proaktive KI')).toBeInTheDocument();
});
});
it('renders enable/disable toggle', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('Proaktive KI')).toBeInTheDocument();
});
});
it('renders category checkboxes', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('E-Mails')).toBeInTheDocument();
expect(screen.getByText('Aufgaben')).toBeInTheDocument();
expect(screen.getByText('Kontakte')).toBeInTheDocument();
expect(screen.getByText('Firmen')).toBeInTheDocument();
expect(screen.getByText('Erkenntnisse')).toBeInTheDocument();
});
});
it('renders confidence threshold section', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('Confidence Schwellwert')).toBeInTheDocument();
});
});
it('renders rate limit section', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('Rate Limit')).toBeInTheDocument();
});
});
it('renders model selection dropdown', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('KI Modell (Ollama Cloud)')).toBeInTheDocument();
});
});
it('renders heartbeat section', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('Heartbeat')).toBeInTheDocument();
});
});
it('renders loading state when settings not loaded', () => {
apiGetMock.mockReturnValue(new Promise(() => {}));
render(<ProactiveAISettings />);
expect(screen.getByText('Lade Einstellungen...')).toBeInTheDocument();
});
it('shows confidence percentage', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('70%')).toBeInTheDocument();
});
});
it('shows rate limit value', async () => {
render(<ProactiveAISettings />);
await waitFor(() => {
expect(screen.getByText('15s')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,93 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SessionList } from '@/components/ai/SessionList';
const mockSessions = [
{ id: 's1', title: 'First Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 's2', title: 'Second Chat', folder_id: 'f1', agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
];
const mockFolders = [
{ id: 'f1', name: 'My Folder', parent_id: null, created_at: '2024-01-01T00:00:00Z' },
];
const fetchSessionsMock = vi.fn();
const fetchFoldersMock = vi.fn();
const createSessionMock = vi.fn();
const deleteSessionMock = vi.fn();
const updateSessionMock = vi.fn();
const createFolderMock = vi.fn();
const deleteFolderMock = vi.fn();
const updateFolderMock = vi.fn();
vi.mock('@/api/ai', () => ({
fetchSessions: (...args: any[]) => fetchSessionsMock(...args),
fetchFolders: (...args: any[]) => fetchFoldersMock(...args),
createSession: (...args: any[]) => createSessionMock(...args),
deleteSession: (...args: any[]) => deleteSessionMock(...args),
updateSession: (...args: any[]) => updateSessionMock(...args),
createFolder: (...args: any[]) => createFolderMock(...args),
deleteFolder: (...args: any[]) => deleteFolderMock(...args),
updateFolder: (...args: any[]) => updateFolderMock(...args),
}));
beforeEach(() => {
vi.clearAllMocks();
fetchSessionsMock.mockResolvedValue(mockSessions);
fetchFoldersMock.mockResolvedValue(mockFolders);
createSessionMock.mockResolvedValue({ id: 's3', title: 'New Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '', updated_at: '' });
deleteSessionMock.mockResolvedValue({});
updateSessionMock.mockResolvedValue({});
createFolderMock.mockResolvedValue({ id: 'f2', name: 'New Folder', parent_id: null, created_at: '' });
deleteFolderMock.mockResolvedValue({});
updateFolderMock.mockResolvedValue({});
});
describe('SessionList', () => {
it('renders session list with sessions', async () => {
render(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('First Chat')).toBeInTheDocument();
});
});
it('renders folder names', async () => {
render(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
});
it('renders sessions inside folders', async () => {
render(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Second Chat')).toBeInTheDocument();
});
});
it('calls onSelectSession when session clicked', async () => {
const onSelect = vi.fn();
render(<SessionList activeSessionId={null} onSelectSession={onSelect} />);
await waitFor(() => {
expect(screen.getByText('First Chat')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('First Chat'));
expect(onSelect).toHaveBeenCalledWith('s1');
});
it('shows loading state initially', () => {
fetchSessionsMock.mockReturnValue(new Promise(() => {}));
fetchFoldersMock.mockReturnValue(new Promise(() => {}));
render(<SessionList activeSessionId={null} onSelectSession={vi.fn()} />);
expect(screen.getByText('Laden...')).toBeInTheDocument();
});
it('displays error when fetch fails', async () => {
fetchSessionsMock.mockRejectedValueOnce(new Error('Failed to load'));
render(<SessionList activeSessionId={null} onSelectSession={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Failed to load')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,127 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SuggestionList, SuggestionSidebar } from '@/components/ai/SuggestionSidebar';
const mockSuggestions = [
{
id: 'sug1',
entity_type: 'mail',
entity_id: 'm1',
suggestion_type: 'info',
title: 'Email suggestion',
content: 'You have an important email',
confidence: 0.85,
actions: [{ method: 'POST', path: '/mail/read', body: {}, description: 'Mark as read' }],
created_at: '2024-01-01T00:00:00Z',
is_dismissed: false,
is_acted_upon: false,
},
{
id: 'sug2',
entity_type: 'task',
entity_id: 't1',
suggestion_type: 'warning',
title: 'Task overdue',
content: 'Task is overdue',
confidence: 0.92,
actions: [],
created_at: '2024-01-01T00:00:00Z',
is_dismissed: false,
is_acted_upon: false,
},
];
const apiGetMock = vi.fn();
const apiPostMock = vi.fn();
const apiPutMock = vi.fn();
vi.mock('@/api/client', () => ({
apiClient: {
get: (...args: any[]) => apiGetMock(...args),
post: (...args: any[]) => apiPostMock(...args),
put: (...args: any[]) => apiPutMock(...args),
},
}));
beforeEach(() => {
vi.clearAllMocks();
apiGetMock.mockResolvedValue({ data: { items: mockSuggestions } });
apiPostMock.mockResolvedValue({ data: {} });
apiPutMock.mockResolvedValue({ data: {} });
});
describe('SuggestionList', () => {
it('renders suggestion list container', async () => {
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByTestId('suggestion-list')).toBeInTheDocument();
});
});
it('renders suggestion titles', async () => {
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByText('Email suggestion')).toBeInTheDocument();
expect(screen.getByText('Task overdue')).toBeInTheDocument();
});
});
it('renders filter buttons', async () => {
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByText('Alle')).toBeInTheDocument();
expect(screen.getByText('Info')).toBeInTheDocument();
expect(screen.getByText('Warnung')).toBeInTheDocument();
});
});
it('renders suggestion count', async () => {
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByText(/2 Vorschläge/)).toBeInTheDocument();
});
});
it('shows empty state when no suggestions', async () => {
apiGetMock.mockResolvedValueOnce({ data: { items: [] } });
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByText('Keine Vorschläge vorhanden')).toBeInTheDocument();
});
});
it('filters suggestions by type', async () => {
render(<SuggestionList />);
await waitFor(() => {
expect(screen.getByText('Email suggestion')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Warnung'));
await waitFor(() => {
expect(screen.getByText('Task overdue')).toBeInTheDocument();
expect(screen.queryByText('Email suggestion')).not.toBeInTheDocument();
});
});
});
describe('SuggestionSidebar', () => {
it('renders sidebar with close button when open', async () => {
render(<SuggestionSidebar isOpen={true} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('KI Vorschläge')).toBeInTheDocument();
expect(screen.getAllByText('✕').length).toBeGreaterThan(0);
});
});
it('calls onClose when close button clicked', async () => {
const onClose = vi.fn();
render(<SuggestionSidebar isOpen={true} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText('KI Vorschläge')).toBeInTheDocument();
});
// The close button is the first ✕ in the sidebar header
const closeButtons = screen.getAllByText('✕');
fireEvent.click(closeButtons[0]);
expect(onClose).toHaveBeenCalled();
});
});