Files
Genarrative/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
suzmii d9c883675d
Project CI / Frontend tests (pull_request) Successful in 4m3s
Project CI / Repository checks (pull_request) Successful in 5m35s
Project CI / Backend tests (pull_request) Successful in 8m10s
Project CI / Native shell tests (pull_request) Failing after 17m32s
补充AGC模型目录异常revision的客户端用例
- 补充旧服务端不返回 revision(两次响应均为 undefined)时仍按目录变化刷新的用例
- 补充服务端目录重建导致 revision 回退(7 到 0)时仍更新界面的用例
- 补充 revision 未变化时不更新界面、沿用已应用目录的用例
2026-09-09 13:03:46 +08:00

473 lines
18 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 { loadClientLlmModels } from '../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: 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('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('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));
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
expect(
screen.getByRole('option', { name: /高质量/ }).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();
});