test(ui): add frontend component tests for LLM management UI (7 tests)

Component tests for all LLM management UI elements using vitest and
@testing-library/react with happy-dom:
- LLMProviders: Tab container rendering
- ModelCombobox: API/recommended/custom tag display, selection, custom input
- ProviderList: Async data loading, enable switches, status indicators
- RoleAssignment: Role card rendering, Radix Select interaction
- TestResultDialog: Success/error state rendering

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 00:33:06 +08:00
committed by 路遥知码力
parent 3937c678f3
commit 31af14a2ca
5 changed files with 333 additions and 0 deletions

View File

@@ -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: () => <div></div>,
}));
vi.mock('../RoleAssignment', () => ({
RoleAssignment: () => <div></div>,
}));
describe('LLMProviders', () => {
it('renders providers and roles sections', () => {
render(<LLMProviders />);
expect(screen.getByText('提供商区域')).toBeInTheDocument();
expect(screen.getByText('角色区域')).toBeInTheDocument();
});
});

View File

@@ -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<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
return {
...actual,
fetchModels: vi.fn(),
};
});
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
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(
<ModelCombobox providerId="p1" providerType="openai_compatible" value="" onChange={onChange} />,
);
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(
<ModelCombobox providerId="p2" providerType="openai_compatible" value="" onChange={onChange} />,
);
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');
});
});
});

View File

@@ -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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
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(<ProviderList />);
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();
});
});

View File

@@ -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<typeof import('@/services/llmProviderService')>('@/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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
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(<RoleAssignment />);
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');
});
});

View File

@@ -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(
<TestResultDialog
open
onOpenChange={vi.fn()}
providerName="DeepSeek"
result={{
success: true,
latencyMs: 123,
model: 'deepseek-chat',
message: '连接已建立',
}}
/>,
);
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(
<TestResultDialog
open
onOpenChange={onOpenChange}
providerName="OpenAI"
result={{
success: false,
latencyMs: 789,
error: '认证失败',
}}
/>,
);
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);
});
});