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);
+ });
+});