d9c883675d
- 补充旧服务端不返回 revision(两次响应均为 undefined)时仍按目录变化刷新的用例 - 补充服务端目录重建导致 revision 回退(7 到 0)时仍更新界面的用例 - 补充 revision 未变化时不更新界面、沿用已应用目录的用例
473 lines
18 KiB
TypeScript
473 lines
18 KiB
TypeScript
// @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();
|
||
});
|