Files
Genarrative/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +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-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();
});