Files
Genarrative/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
kdletters c36a5170f8
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m44s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Repository checks (push) Successful in 4m17s
Project CI / Backend tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
AGC 新增本地自定义 LLM 配置与展示模型勾选
- 本地配置新增 llm.customEnabled 与 llm.visibleModels,显式开启后保留自定义连接和勾选列表,官方路由下仍清空凭据
- 配置迁移始终写出 customEnabled、visibleModels、apiKey、baseUrl、model、apiKind、reasoningEffort,便于手写自定义连接
- 新增 discover_game_creator_llm_models 命令,直连自定义端点 GET /models,限制超时与响应大小并脱敏错误
- codex_app_server 自定义模式改用配置里的地址与 Key 走本地凭据代理,不再经过平台 /api/llm,也不回退官方路由
- 模型目录按官方与自定义来源隔离缓存,自定义模式只展示勾选模型,选项失效时回退第一项
- 设置页在自定义模式展示 API 地址、API Key、协议与推理档,并提供模型读取、搜索、勾选与已勾选预览
- 补齐固定项排版与读取按钮样式,同步 AGC 模型选择规范、实施计划与共享概览
2026-09-16 16:39:15 +08:00

738 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { resolveTauriInvoke } from '../src/app/tauri';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect';
import {
ClientAuthRequestError,
type ClientLlmModelCatalog,
loadClientLlmModels,
} from '../src/services/clientApi';
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
import {
notifyLlmConfigChanged,
resetLlmModelCatalogCacheForTest,
} from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
const MockClientAuthRequestError = vi.hoisted(
() =>
class MockClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
},
);
vi.mock('../src/services/clientApi', () => ({
ClientAuthRequestError: MockClientAuthRequestError,
loadClientLlmModels: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
const invoke = vi.fn();
let savedModelId = 'quality';
let savedModelIsDefault = true;
beforeEach(() => {
vi.clearAllMocks();
resetLlmModelCatalogCacheForTest();
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
savedModelId = 'quality';
savedModelIsDefault = true;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
});
afterEach(cleanup);
test('turning custom mode off cannot reuse custom models when the official catalog fails', async () => {
let customEnabled = true;
savedModelId = 'custom-model';
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled,
baseUrl: 'https://custom.example/v1',
visibleModels: ['custom-model'],
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const onReady = await renderReadyModelMenu();
expect(screen.getByRole('option', { name: /custom-model/ })).not.toBeNull();
customEnabled = false;
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(
new Error('unavailable'),
);
fireEvent(window, new Event('focus'));
await screen.findByText('模型列表加载失败');
expect(onReady).toHaveBeenLastCalledWith(false);
expect(screen.queryByRole('option', { name: /custom-model/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByRole('option', { name: /高质量/ });
expect(savedModelId).toBe('quality');
});
test('custom mode only shows checked endpoint model IDs and never requests the platform catalog', async () => {
savedModelId = 'vendor/model.v1';
const models = ['vendor/model.v1', 'vendor/fast:latest'];
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled: true,
baseUrl: 'https://custom.example/v1',
visibleModels: models,
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
await renderReadyModelMenu();
expect(screen.getAllByRole('option')).toHaveLength(2);
fireEvent.click(screen.getByRole('option', { name: 'vendor/fast:latest' }));
await waitFor(() => expect(savedModelId).toBe('vendor/fast:latest'));
expect(loadClientLlmModels).not.toHaveBeenCalled();
});
test('saving a custom catalog replaces official models and falls back when the old selection is unchecked', async () => {
await renderReadyModelMenu();
vi.mocked(loadClientLlmModels).mockClear();
let models = ['custom.v1', 'custom.v2'];
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = input.modelId;
savedModelIsDefault = input.isDefault;
}
return {
config: {
llm: {
customEnabled: true,
baseUrl: 'https://custom.example/v1',
visibleModels: models,
},
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
act(() => notifyLlmConfigChanged());
await waitFor(() => expect(savedModelId).toBe('custom.v1'));
expect(screen.queryByRole('option', { name: /高质量/ })).toBeNull();
expect(screen.getAllByRole('option')).toHaveLength(2);
models = ['custom.v2'];
act(() => notifyLlmConfigChanged());
await waitFor(() => expect(savedModelId).toBe('custom.v2'));
expect(screen.getAllByRole('option')).toHaveLength(1);
expect(loadClientLlmModels).not.toHaveBeenCalled();
});
async function renderReadyModelMenu() {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
});
return onReady;
}
test('shows manual refresh progress immediately without clearing the selected model', async () => {
const onReady = await renderReadyModelMenu();
let resolveRefresh!: (catalog: ClientLlmModelCatalog) => void;
vi.mocked(loadClientLlmModels).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
const refreshButton = screen.getByRole('button', { name: '刷新模型列表' });
expect(refreshButton.textContent).toBe('刷新中…');
expect(refreshButton).toHaveProperty('disabled', true);
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(onReady).toHaveBeenLastCalledWith(false);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
await waitFor(() => expect(resolveRefresh).toBeTypeOf('function'));
await act(async () => {
resolveRefresh({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 1,
});
});
expect(screen.getByRole('status').textContent).toBe('模型列表已刷新');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('confirms a manual refresh even when the catalog revision is unchanged', async () => {
await renderReadyModelMenu();
expect(screen.queryByRole('status')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(loadClientLlmModels).toHaveBeenCalledTimes(3);
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
});
test.each([
[
'HTTP 404',
new ClientAuthRequestError('private server detail', { status: 404 }),
'模型列表加载失败(HTTP 404',
],
[
'HTTP 401',
new ClientAuthRequestError('private server detail', { status: 401 }),
'模型列表加载失败(HTTP 401',
],
[
'timeout',
new ClientHttpTimeoutError('https://private.example/models', 15000),
'模型列表请求超时,请重试',
],
['unknown', new Error('private server detail'), '模型列表加载失败'],
])(
'reports a safe %s failure with cached models and permits retry without claiming success',
async (_label, failure, message) => {
const onReady = await renderReadyModelMenu();
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(failure);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toBe(message),
);
expect(screen.queryByText('模型列表已刷新')).toBeNull();
expect(screen.queryByText('正在刷新模型列表')).toBeNull();
expect(document.body.textContent).not.toContain('private');
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
expect(
screen
.getByRole('option', { name: /高质量/ })
.getAttribute('aria-selected'),
).toBe('true');
expect(savedModelId).toBe('quality');
expect(onReady).toHaveBeenLastCalledWith(true);
expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
'disabled',
false,
);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await screen.findByText('模型列表已刷新');
expect(screen.queryByRole('alert')).toBeNull();
},
);
test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
expect(screen.queryByText('gpt-6-astra')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
}),
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
test('falls back to the default model when the saved selection was removed', async () => {
invoke.mockImplementation(async (command, input) => ({
config: {
selectedModelId:
command === 'select_game_creator_model'
? (input as { modelId: string }).modelId
: 'private-old-model',
selectedModelIsDefault:
command === 'select_game_creator_model'
? Boolean((input as { isDefault: boolean }).isDefault)
: false,
},
}));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('所选模型已停用,已切换为默认模型');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('private-old-model')).toBeNull();
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'quality',
isDefault: true,
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
});
test('failed catalog can be refreshed without enabling submission', async () => {
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('模型列表加载失败');
expect(onReady).toHaveBeenLastCalledWith(false);
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
});
test('a failed save keeps submission unavailable', async () => {
invoke.mockImplementation(async (command) => {
if (command === 'select_game_creator_model') throw new Error('disk full');
return {
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
};
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await screen.findByText('模型选择保存失败');
expect(onReady).toHaveBeenLastCalledWith(false);
});
test('keeps the open menu inside its own trigger container, not the control row', async () => {
const onReady = vi.fn();
const { container } = render(
<>
<div className="project-supervisor-composer-controls-right">
<ConversationModelSelect disabled={false} onReady={onReady} />
</div>
</>,
);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
// 弹层必须留在触发钮自己那颗容器里:它是 absolute + bottom: 100% 的浮层,锚点由这颗
// 容器提供;一旦被挪到控制排(或其它同排容器)当流式子节点,就会参与那一排的布局。
const modelSelect = container.querySelector('.conversation-model-select');
expect(modelSelect).not.toBeNull();
expect(
modelSelect?.querySelector(':scope > .conversation-model-menu'),
).not.toBeNull();
expect(
container.querySelector(
'.project-supervisor-composer-controls-right > .conversation-model-menu',
),
).toBeNull();
});
test('closes the menu when clicking outside', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
fireEvent.mouseDown(document.body);
await waitFor(() =>
expect(screen.queryByRole('option', { name: '快速' })).toBeNull(),
);
});
test('closes the menu on Escape', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('option', { name: '快速' })).toBeNull(),
);
});
test('marks the default model in the menu', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
const qualityOption = screen.getByRole('option', { name: /高质量/ });
expect(within(qualityOption).getByText('默认')).not.toBeNull();
const fastOption = screen.getByRole('option', { name: '快速' });
expect(within(fastOption).queryByText('默认')).toBeNull();
});
test('keeps model options disabled while a selection save is in flight', async () => {
let resolveSave: ((value: unknown) => void) | undefined;
invoke.mockImplementation(async (command) => {
if (command === 'select_game_creator_model') {
return new Promise((resolve) => {
resolveSave = resolve;
});
}
return {
config: { selectedModelId: 'quality', selectedModelIsDefault: true },
};
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await screen.findByRole('option', { name: '快速' });
fireEvent.click(screen.getByRole('option', { name: '快速' }));
// 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty(
'disabled',
true,
);
expect(onReady).toHaveBeenLastCalledWith(false);
// 配置写回按队列落盘,保存请求在下一个微任务才发出。
await waitFor(() => expect(resolveSave).toBeDefined());
resolveSave?.({ config: { selectedModelId: 'fast' } });
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
'disabled',
false,
);
});
test('keeps the last good catalog when a background refresh fails', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockRejectedValueOnce(new Error('offline'));
fireEvent(window, new Event('focus'));
await screen.findByText('模型列表加载失败');
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: /高质量/ })).not.toBeNull();
expect(onReady).toHaveBeenLastCalledWith(true);
});
test('applies a new catalog revision on focus', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
{ id: 'vision', displayName: '视觉' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull();
});
test('pre-send validation falls back when the selected model is disabled', async () => {
let savedModelId = 'quality';
let savedModelIsDefault = false;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() => expect(savedModelId).toBe('fast'));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [{ id: 'quality', displayName: '高质量' }],
revision: 2,
});
await act(async () => {
await expect(ref.current?.ensureUsable()).resolves.toBe(true);
});
expect(screen.getByText('所选模型已停用,已切换为默认模型')).not.toBeNull();
expect(savedModelId).toBe('quality');
});
test('follows the new server default when the saved selection was the default', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'fast',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(savedModelId).toBe('fast'));
expect(savedModelIsDefault).toBe(true);
expect(
screen.getByText('默认模型已更新,已切换为新的默认模型'),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
test('keeps an explicit selection when the server default changes', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() => expect(savedModelId).toBe('fast'));
expect(savedModelIsDefault).toBe(false);
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 2,
});
fireEvent(window, new Event('focus'));
await waitFor(() =>
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速'),
);
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('recovers the selector when reading the native config fails', async () => {
invoke.mockRejectedValueOnce(new Error('config unreadable'));
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByText('读取客户端配置失败');
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false));
// 配置不可读时不能猜测官方路由;恢复读取后选项与刷新按钮重新可用。
expect(loadClientLlmModels).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
const option = await screen.findByRole('option', { name: /高质量/ });
expect(option.hasAttribute('disabled')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(screen.queryByText('读取客户端配置失败')).toBeNull();
});
test('pre-send validation waits for an in-flight selection save', async () => {
let resolveSave: (() => void) | undefined;
invoke.mockImplementation(async (command, input) => {
if (command === 'select_game_creator_model') {
await new Promise<void>((resolve) => {
resolveSave = () => {
savedModelId = String(input.modelId);
savedModelIsDefault = Boolean(input.isDefault);
resolve();
};
});
}
return {
config: {
selectedModelId: savedModelId,
selectedModelIsDefault: savedModelIsDefault,
},
};
});
const ref = createRef<ConversationModelSelectHandle>();
const onReady = vi.fn();
render(
<ConversationModelSelect ref={ref} disabled={false} onReady={onReady} />,
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
let settled = false;
let pending!: Promise<boolean>;
await act(async () => {
pending = ref.current!.ensureUsable().finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(settled).toBe(false);
resolveSave?.();
await act(async () => {
await expect(pending).resolves.toBe(true);
});
expect(savedModelId).toBe('fast');
expect(savedModelIsDefault).toBe(false);
});
test('reuses a single in-flight catalog request', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
expect(loadClientLlmModels).toHaveBeenCalledTimes(1);
fireEvent(window, new Event('focus'));
fireEvent(window, new Event('focus'));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
expect(loadClientLlmModels).toHaveBeenCalledTimes(2);
});
test('keeps refreshing when the server omits the catalog revision', async () => {
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: undefined as unknown as number,
});
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// 旧服务端不返回 revision 时,两次响应的 revision 都是 undefined
// 不能因此判定「未变化」而停止刷新。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: undefined as unknown as number,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
});
test('applies a catalog when the revision goes backwards', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// 服务端目录重建后 revision 可能回退,仍要按「已变化」处理。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: 0,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(await screen.findByRole('option', { name: '视觉' })).not.toBeNull();
});
test('keeps the applied catalog when the revision is unchanged', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
// revision 未变化时不更新界面,沿用已应用的目录。
vi.mocked(loadClientLlmModels).mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'vision', displayName: '视觉' },
],
revision: 1,
});
fireEvent(window, new Event('focus'));
await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(screen.queryByRole('option', { name: '视觉' })).toBeNull();
expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
});