// @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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); const onReady = vi.fn(); render( , ); 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(); 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(); 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(); 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((resolve) => { resolveSave = () => { savedModelId = String(input.modelId); savedModelIsDefault = Boolean(input.isDefault); resolve(); }; }); } return { config: { selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, }, }; }); const ref = createRef(); const onReady = vi.fn(); render( , ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); fireEvent.click(screen.getByRole('option', { name: '快速' })); let settled = false; let pending!: Promise; 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(); 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(); 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(); 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(); 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(); });