Files
Genarrative/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
T
kdletters 1e62d8317a 合并 origin/master 到 AGC 渲染层下沉分支并完成三处对齐
- 活动回合事实源统一到 Direct 线程管理器:删除 direct_runtime 的第二份快照,接单、进度内容变化与收口各广播一次活动回合变更事件
- 平台维护态判定移入 Rust 并在渲染层只订阅单一事件:新增 platform_maintenance 模块与各平台 facade 错误分支的分类入口
- 封面生成请求补 generationInputs.source,保持队列回填后仍能拿到平台素材 ID
- 渲染层按 master 5398a53e6 退役诊断详情入口:删除 agentRuntimeErrorDetail 与「查看详情」交互及其专属用例
- 冲突收口:nginx SPA 白名单、.gitignore、mobile 检查脚本、capabilities 描述取上游,两个已退役计划随上游删除,文档保留双方条目
- 新增并回写本里程碑取证、decision-log 与 pitfalls 的 2026-09-28 记录
2026-09-28 15:07:30 +08:00

718 lines
27 KiB
TypeScript
Raw 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 {
type ClientLlmModelCatalog,
notifyLlmConfigChanged,
resetLlmModelCatalogCacheForTest,
} from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
/**
* 目录读取现在由 Rust typed command 提供:官方目录替身只需接管
* `load_game_creator_llm_models` 的返回值,目录缓存、单飞与自定义模式语义仍走真实实现。
*/
const loadClientLlmModels = vi.hoisted(() => vi.fn());
const invoke = vi.fn();
const nativeInvoke = vi.fn((command: string, input: unknown) =>
command === 'load_game_creator_llm_models'
? loadClientLlmModels()
: invoke(command, input),
);
let savedModelId = 'quality';
let savedModelIsDefault = true;
beforeEach(() => {
vi.clearAllMocks();
resetLlmModelCatalogCacheForTest();
vi.mocked(resolveTauriInvoke).mockReturnValue(nativeInvoke);
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([
[
'登录失效',
new Error('authentication-required: 陶泥儿登录态已过期'),
'模型列表加载失败:登录已失效,请重新登录',
],
[
'network-error',
new Error('network-error: 无法连接登录服务'),
'模型列表加载失败',
],
['timeout', new Error('模型目录请求超时'), '模型列表请求超时,请重试'],
['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('quality')).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-chat-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-chat-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();
});