2e5dc57e9d
针对 review 提出的 P2:保存 select_game_creator_model 期间重新打开菜单再选一次会挂起两条 invoke,完成第一条即解锁发送导致显示/配置错乱 保存期间禁用下拉选项(可查看菜单,不可再选),保存完成后恢复;补充并发回归测试
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import { resolveTauriInvoke } from '../src/app/tauri';
|
|
import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect';
|
|
import { loadClientLlmModels } from '../src/services/clientApi';
|
|
|
|
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
|
|
vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
|
|
const invoke = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(resolveTauriInvoke).mockReturnValue(invoke);
|
|
vi.mocked(loadClientLlmModels).mockResolvedValue({
|
|
defaultModelId: 'quality',
|
|
models: [
|
|
{ id: 'quality', displayName: '高质量' },
|
|
{ id: 'fast', displayName: '快速' },
|
|
],
|
|
});
|
|
invoke.mockImplementation(async (command, input) => ({
|
|
config: {
|
|
selectedModelId:
|
|
command === 'select_game_creator_model' ? input.modelId : 'quality',
|
|
},
|
|
}));
|
|
});
|
|
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',
|
|
}),
|
|
);
|
|
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',
|
|
},
|
|
}));
|
|
const onReady = vi.fn();
|
|
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
|
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
|
expect(screen.queryByText('private-old-model')).toBeNull();
|
|
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
|
modelId: 'quality',
|
|
});
|
|
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' } };
|
|
});
|
|
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, input) => {
|
|
if (command === 'select_game_creator_model') {
|
|
return new Promise((resolve) => {
|
|
resolveSave = resolve;
|
|
});
|
|
}
|
|
return { config: { selectedModelId: 'quality' } };
|
|
});
|
|
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);
|
|
|
|
resolveSave?.({ config: { selectedModelId: 'fast' } });
|
|
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
|
expect(screen.getByRole('option', { name: '快速' })).toHaveProperty(
|
|
'disabled',
|
|
false,
|
|
);
|
|
});
|