// @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 { notifyLlmConfigChanged, 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); test('turning custom mode off cannot reuse custom models when the official catalog fails', async () => { let customEnabled = true; savedModelId = 'custom-model'; invoke.mockImplementation(async (command, input) => { if (command === 'select_game_creator_model') { savedModelId = input.modelId; savedModelIsDefault = input.isDefault; } return { config: { llm: { customEnabled, baseUrl: 'https://custom.example/v1', visibleModels: ['custom-model'], }, selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, }, }; }); const onReady = await renderReadyModelMenu(); expect(screen.getByRole('option', { name: /custom-model/ })).not.toBeNull(); customEnabled = false; vi.mocked(loadClientLlmModels).mockRejectedValueOnce( new Error('unavailable'), ); fireEvent(window, new Event('focus')); await screen.findByText('模型列表加载失败'); expect(onReady).toHaveBeenLastCalledWith(false); expect(screen.queryByRole('option', { name: /custom-model/ })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); await screen.findByRole('option', { name: /高质量/ }); expect(savedModelId).toBe('quality'); }); test('custom mode only shows checked endpoint model IDs and never requests the platform catalog', async () => { savedModelId = 'vendor/model.v1'; const models = ['vendor/model.v1', 'vendor/fast:latest']; invoke.mockImplementation(async (command, input) => { if (command === 'select_game_creator_model') { savedModelId = input.modelId; savedModelIsDefault = input.isDefault; } return { config: { llm: { customEnabled: true, baseUrl: 'https://custom.example/v1', visibleModels: models, }, selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, }, }; }); await renderReadyModelMenu(); expect(screen.getAllByRole('option')).toHaveLength(2); fireEvent.click(screen.getByRole('option', { name: 'vendor/fast:latest' })); await waitFor(() => expect(savedModelId).toBe('vendor/fast:latest')); expect(loadClientLlmModels).not.toHaveBeenCalled(); }); test('saving a custom catalog replaces official models and falls back when the old selection is unchecked', async () => { await renderReadyModelMenu(); vi.mocked(loadClientLlmModels).mockClear(); let models = ['custom.v1', 'custom.v2']; invoke.mockImplementation(async (command, input) => { if (command === 'select_game_creator_model') { savedModelId = input.modelId; savedModelIsDefault = input.isDefault; } return { config: { llm: { customEnabled: true, baseUrl: 'https://custom.example/v1', visibleModels: models, }, selectedModelId: savedModelId, selectedModelIsDefault: savedModelIsDefault, }, }; }); act(() => notifyLlmConfigChanged()); await waitFor(() => expect(savedModelId).toBe('custom.v1')); expect(screen.queryByRole('option', { name: /高质量/ })).toBeNull(); expect(screen.getAllByRole('option')).toHaveLength(2); models = ['custom.v2']; act(() => notifyLlmConfigChanged()); await waitFor(() => expect(savedModelId).toBe('custom.v2')); expect(screen.getAllByRole('option')).toHaveLength(1); expect(loadClientLlmModels).not.toHaveBeenCalled(); }); async function renderReadyModelMenu() { const onReady = vi.fn(); render(); 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 waitFor(() => expect(resolveRefresh).toBeTypeOf('function')); 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(); 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('keeps the open menu inside its own trigger container, not the control row', async () => { const onReady = vi.fn(); const { container } = render( <>
, ); 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(); 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)); // 配置不可读时不能猜测官方路由;恢复读取后选项与刷新按钮重新可用。 expect(loadClientLlmModels).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); const option = await screen.findByRole('option', { name: /高质量/ }); expect(option.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(); });