dc8e03988f
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m20s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m8s
Project CI / AI game creator shell Rust crates (push) Successful in 3m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m4s
Project CI / Repository checks (push) Successful in 3m27s
Project CI / Frontend tests (push) Successful in 4m39s
Project CI / Native shell tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 8m47s
ConversationModelSelect:手动刷新立即显示「刷新中…」,真实刷新成功后显示「模型列表已刷新」,revision 未变化也有反馈 ConversationModelSelect:刷新失败按 HTTP 状态与超时给出可辨认提示,沿用有效缓存时不报告刷新成功,已选模型与挂载/聚焦语义不变 clientApi:成功与错误响应体统一走 readClientHttpResponseText 的 15 秒上限,响应体卡住时结束等待并释放模型目录在途请求 测试:补齐模型目录响应体超时后保留缓存并可重试、迟到响应不覆盖新目录、手动刷新进行中/同版本成功/失败反馈用例 文档:同步 AGC 模型目录刷新反馈与响应体超时合同,并在共享记忆记录该排障口径
640 lines
24 KiB
TypeScript
640 lines
24 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 {
|
||
ClientAuthRequestError,
|
||
type ClientLlmModelCatalog,
|
||
loadClientLlmModels,
|
||
} from '../src/services/clientApi';
|
||
import { ClientHttpTimeoutError } from '../src/services/clientHttp';
|
||
import { 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);
|
||
|
||
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 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-supervisor-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-supervisor-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));
|
||
|
||
// 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。
|
||
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();
|
||
});
|