diff --git a/frontend/src/components/llm/__tests__/LLMProviders.test.tsx b/frontend/src/components/llm/__tests__/LLMProviders.test.tsx new file mode 100644 index 0000000..6addb8f --- /dev/null +++ b/frontend/src/components/llm/__tests__/LLMProviders.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LLMProviders } from '../LLMProviders'; + +vi.mock('../ProviderList', () => ({ + ProviderList: () =>
提供商区域
, +})); + +vi.mock('../RoleAssignment', () => ({ + RoleAssignment: () =>
角色区域
, +})); + +describe('LLMProviders', () => { + it('renders providers and roles sections', () => { + render(); + + expect(screen.getByText('提供商区域')).toBeInTheDocument(); + expect(screen.getByText('角色区域')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/llm/__tests__/ModelCombobox.test.tsx b/frontend/src/components/llm/__tests__/ModelCombobox.test.tsx new file mode 100644 index 0000000..25ab733 --- /dev/null +++ b/frontend/src/components/llm/__tests__/ModelCombobox.test.tsx @@ -0,0 +1,71 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { ModelCombobox } from '../ModelCombobox'; +import { fetchModels } from '@/services/llmProviderService'; + +vi.mock('@/services/llmProviderService', async () => { + const actual = await vi.importActual('@/services/llmProviderService'); + return { + ...actual, + fetchModels: vi.fn(), + }; +}); + +function renderWithQuery(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render({ui}); +} + +describe('ModelCombobox', () => { + it('shows API tag and selects API model', async () => { + vi.mocked(fetchModels).mockResolvedValueOnce(['api-model-1']); + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithQuery( + , + ); + + const input = screen.getByPlaceholderText('选择或输入模型...'); + await user.click(input); + + expect(await screen.findByText('api-model-1')).toBeInTheDocument(); + expect(screen.getByText('API')).toBeInTheDocument(); + + await user.click(screen.getByText('api-model-1')); + expect(onChange).toHaveBeenCalledWith('api-model-1'); + }); + + it('shows 推荐 and 自定义 tags and supports custom input', async () => { + vi.mocked(fetchModels).mockResolvedValueOnce([]); + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithQuery( + , + ); + + const input = screen.getByPlaceholderText('选择或输入模型...'); + await user.click(input); + + expect((await screen.findAllByText('推荐')).length).toBeGreaterThan(0); + expect(screen.getByText('gpt-4o')).toBeInTheDocument(); + + await user.clear(input); + await user.type(input, 'my-custom-model'); + + expect(await screen.findByText('自定义')).toBeInTheDocument(); + await user.click(screen.getByText('my-custom-model')); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('my-custom-model'); + }); + }); +}); diff --git a/frontend/src/components/llm/__tests__/ProviderList.test.tsx b/frontend/src/components/llm/__tests__/ProviderList.test.tsx new file mode 100644 index 0000000..34bf799 --- /dev/null +++ b/frontend/src/components/llm/__tests__/ProviderList.test.tsx @@ -0,0 +1,90 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { ProviderList } from '../ProviderList'; +import { + fetchProviders, + fetchRoles, + updateProvider, + deleteProvider, + testProvider, +} from '@/services/llmProviderService'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/services/llmProviderService', () => ({ + fetchProviders: vi.fn(), + fetchRoles: vi.fn(), + updateProvider: vi.fn(), + deleteProvider: vi.fn(), + testProvider: vi.fn(), +})); + +function renderWithQuery(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +describe('ProviderList', () => { + it('renders providers, enable states and hasKey indicators', async () => { + vi.mocked(fetchProviders).mockResolvedValueOnce([ + { + id: 'p1', + name: 'OpenAI 官方', + type: 'openai_responses', + baseUrl: null, + defaultModel: 'gpt-4o', + isEnabled: true, + hasKey: true, + extraConfig: {}, + createdAt: '2026-01-01', + }, + { + id: 'p2', + name: '本地兼容服务', + type: 'openai_compatible', + baseUrl: 'https://example.com/v1', + defaultModel: 'qwen-plus', + isEnabled: false, + hasKey: false, + extraConfig: {}, + createdAt: '2026-01-01', + }, + ]); + vi.mocked(fetchRoles).mockResolvedValueOnce([]); + vi.mocked(updateProvider).mockResolvedValue({} as never); + vi.mocked(deleteProvider).mockResolvedValue(undefined); + vi.mocked(testProvider).mockResolvedValue({ success: true }); + + renderWithQuery(); + + expect(await screen.findByText('模型提供商')).toBeInTheDocument(); + expect(await screen.findByText('OpenAI 官方')).toBeInTheDocument(); + expect(await screen.findByText('本地兼容服务')).toBeInTheDocument(); + expect(screen.getByText('OpenAI Responses')).toBeInTheDocument(); + expect(screen.getByText('OpenAI 兼容')).toBeInTheDocument(); + expect(screen.getByText('就绪')).toBeInTheDocument(); + expect(screen.getByText('无 Key')).toBeInTheDocument(); + + const switches = screen.getAllByRole('switch'); + expect(switches).toHaveLength(2); + expect(switches[0]).toHaveAttribute('data-state', 'checked'); + expect(switches[1]).toHaveAttribute('data-state', 'unchecked'); + + const testButtons = screen.getAllByTitle('测试连接'); + expect(testButtons).toHaveLength(2); + expect(testButtons[0]).toBeEnabled(); + expect(testButtons[1]).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/llm/__tests__/RoleAssignment.test.tsx b/frontend/src/components/llm/__tests__/RoleAssignment.test.tsx new file mode 100644 index 0000000..1ece5f3 --- /dev/null +++ b/frontend/src/components/llm/__tests__/RoleAssignment.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { RoleAssignment } from '../RoleAssignment'; +import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/services/llmProviderService', async () => { + const actual = await vi.importActual('@/services/llmProviderService'); + return { + ...actual, + fetchProviders: vi.fn(), + fetchRoles: vi.fn(), + setRole: vi.fn(), + fetchModels: vi.fn().mockResolvedValue([]), + }; +}); + +function renderWithQuery(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render({ui}); +} + +describe('RoleAssignment', () => { + it('renders role cards and supports provider/model editing', async () => { + vi.mocked(fetchProviders).mockResolvedValueOnce([ + { + id: 'p1', + name: 'OpenAI', + type: 'openai_responses', + baseUrl: null, + defaultModel: 'gpt-4o-mini', + isEnabled: true, + hasKey: true, + extraConfig: {}, + createdAt: '2026-01-01', + }, + ]); + + vi.mocked(fetchRoles).mockResolvedValueOnce([ + { + role: 'legacy', + providerId: 'p1', + providerName: 'OpenAI', + providerType: 'openai_responses', + model: 'gpt-4o', + }, + ]); + + vi.mocked(setRole).mockResolvedValue({ + role: 'planner', + providerId: 'p1', + providerName: 'OpenAI', + providerType: 'openai_responses', + model: 'custom-planner-model', + }); + + const user = userEvent.setup(); + renderWithQuery(); + + expect(await screen.findByText('角色分配')).toBeInTheDocument(); + expect(await screen.findByText('Legacy 审查')).toBeInTheDocument(); + expect(await screen.findByText('规划器 Planner')).toBeInTheDocument(); + + // Radix Select renders placeholder in a span with pointer-events: none. + // Click the trigger button (parent) instead of the placeholder text. + const providerPlaceholders = screen.getAllByText('选择提供商'); + const triggerButton = providerPlaceholders[0].closest('button')!; + await user.click(triggerButton); + await user.click(await screen.findByRole('option', { name: /OpenAI/ })); + + const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[]; + await waitFor(() => { + expect(modelInputs[1].value).toBe('gpt-4o-mini'); + }); + + await user.clear(modelInputs[1]); + await user.type(modelInputs[1], 'custom-planner-model'); + expect(modelInputs[1].value).toBe('custom-planner-model'); + }); +}); diff --git a/frontend/src/components/llm/__tests__/TestResultDialog.test.tsx b/frontend/src/components/llm/__tests__/TestResultDialog.test.tsx new file mode 100644 index 0000000..8f3a901 --- /dev/null +++ b/frontend/src/components/llm/__tests__/TestResultDialog.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { TestResultDialog } from '../TestResultDialog'; + +describe('TestResultDialog', () => { + it('renders success state with latency, model and message', () => { + render( + , + ); + + expect(screen.getByText('测试结果 - DeepSeek')).toBeInTheDocument(); + expect(screen.getByText('连接成功')).toBeInTheDocument(); + expect(screen.getByText('延迟:')).toBeInTheDocument(); + expect(screen.getByText('123 ms')).toBeInTheDocument(); + expect(screen.getByText('模型:')).toBeInTheDocument(); + expect(screen.getByText('deepseek-chat')).toBeInTheDocument(); + expect(screen.getByText('AI 响应:')).toBeInTheDocument(); + expect(screen.getByText('连接已建立')).toBeInTheDocument(); + }); + + it('renders error state and closes via button', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.getByText('测试失败')).toBeInTheDocument(); + expect(screen.getByText('延迟:')).toBeInTheDocument(); + expect(screen.getByText('789 ms')).toBeInTheDocument(); + expect(screen.getByText('错误:')).toBeInTheDocument(); + expect(screen.getByText('认证失败')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '关闭' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +});