ff287c19fc
- styles.css:direct-codex 控制排里的 .conversation-model-select 由 position: static !important 改为 position: relative !important(只改这一个词,另补一段注释说明它同时是弹层锚点)。这颗容器是 .conversation-model-menu(position: absolute; right: 0; bottom: calc(100% + 8px))与模型提示([role='alert'] / [role='status'],position: absolute; bottom: 36px)的包含块;写成 static 时包含块回落到最近的有定位祖先——也就是 position: absolute 的 composer 盒子,菜单于是出现在整个 composer 上方、与触发钮之间隔着整个输入区,输入区一变高(多行、引用 chip、AI 润色)菜单与提示跟着往上飘:这就是"编辑框把元素挤开"的形态。 - styles.css:right/bottom: auto、flex: 0 1 auto、min-width: 0、max-width: 180px 全部保持原样——relative 不会把模型选择钮移出控制排,也不改它被压缩换行的行为;交互语义零改动(打开/选择/刷新模型、发送、@ 引用、快速模式全在原位)。 - project-development.suite.ts:新增一条声明级守卫。① 弹层不参与父容器流式布局(菜单与模型提示都是 absolute,菜单 bottom: calc(100% + …)、right: 0);② 锚点必须是有定位的容器(relative 且不是 static),同时仍在原位(right/bottom auto、flex 0 1 auto、min-width 0);③ 输入区高度变化不得推动同排操作元素(composer 是底边锚定的两行网格:position: absolute + bottom: 12px + grid-template-rows: auto auto;控制排是单行 flex 且不换行,@ 与发送钮 flex: 0 0 30px 不可压缩,窄屏下被压缩的是可收缩的模型选择钮)。 - conversationModelSelect.test.tsx:新增一条 DOM 守卫——菜单必须是 .conversation-model-select 的直接子节点,且不得成为控制排容器 .project-supervisor-composer-controls-right 的流式子节点。 - 变异验证(均已实测红灯):① 锚点改回 static → "expected … to match /position:\s*relative\s*!important/";② 菜单改成 position: static(弹层回流式)→ "expected … to match /position:\s*absolute/";③ 把菜单渲染挪出触发钮容器(变成同排容器的流式子节点)→ DOM 守卫 "expected null not to be null"。 - 门禁:typecheck exit 0;AGC 子集 1205 passed / 4 skipped / 0 failed(基线 1203 + 本次新增 2 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;本次三个文件单独 `git diff --check` exit 0(全量 `git diff --check` 目前报的是另一位 Agent 在途修改的 packages/shared/src/components/styles.css:1281 EOF 空行,与本次无关)。
499 lines
19 KiB
TypeScript
499 lines
19 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('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();
|
||
});
|