// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { resolveTauriInvoke } from '../src/app/tauri'; import { ConversationModelSelect } from '../src/features/project-workspace/ConversationModelSelect'; import { loadClientLlmModels } from '../src/services/clientApi'; vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() })); const invoke = vi.fn(); beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); vi.mocked(loadClientLlmModels).mockResolvedValue({ defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'fast', displayName: '快速' }, ], }); invoke.mockImplementation(async (command, input) => ({ config: { selectedModelId: command === 'select_game_creator_model' ? input.modelId : 'quality', }, })); }); 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', }), ); 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', }, })); const onReady = vi.fn(); render(); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'quality', }); 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' } }; }); 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, input) => { if (command === 'select_game_creator_model') { return new Promise((resolve) => { resolveSave = resolve; }); } return { config: { selectedModelId: 'quality' } }; }); 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); resolveSave?.({ config: { selectedModelId: 'fast' } }); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( 'disabled', false, ); });