import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; const interactionApi = vi.hoisted(() => ({ respond: vi.fn(), abort: vi.fn(), compact: vi.fn(), model: vi.fn(), thinking: vi.fn(), diagnostics: vi.fn(), })); const inspectorApi = vi.hoisted(() => ({ changes: vi.fn(), commands: vi.fn(), content: vi.fn(), files: vi.fn(), find: vi.fn(), skills: vi.fn(), })); vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ ...await importOriginal(), respondCodingConversationInteraction: interactionApi.respond, abortCodingConversation: interactionApi.abort, compactCodingConversation: interactionApi.compact, setCodingConversationModel: interactionApi.model, setCodingConversationThinking: interactionApi.thinking, getCodingRuntimeDiagnostics: interactionApi.diagnostics, })); vi.mock('@/lib/coding-product-tools', () => ({ getCodingConversationChanges: inspectorApi.changes, getCodingConversationCommands: inspectorApi.commands, getCodingFileContent: inspectorApi.content, getCodingFileStatus: inspectorApi.files, findCodingFiles: inspectorApi.find, getCodingSkills: inspectorApi.skills, })); describe('PI-130 feature-complete Coding UI', () => { it('builds vendor-neutral Conversation model choices without runtime keys', async () => { const { buildCodingModelOptions, parseCodingModelKey } = await import( '@/lib/coding-model-options' ); const account = { id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b', 'model-a'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', } satisfies ProviderAccount; const vendor = { id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo; const options = buildCodingModelOptions([account], [vendor]); expect(options.map((option) => option.modelId)).toEqual(['model-a', 'model-b']); expect(options[0].label).toBe('Work account · Custom vendor / model-a'); expect(parseCodingModelKey(options[0].key)).toEqual({ accountId: 'account-1', modelId: 'model-a' }); expect(JSON.stringify(options)).not.toMatch(/opencode|providerID/i); }); it('exposes steer/follow-up modes and queue waiting state while a turn runs', async () => { const onModeChange = vi.fn(); const onSubmit = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByText('1 条消息正在等待;本轮结算后会继续处理。')).toBeInTheDocument(); expect(screen.getByText('第 1 位 · 下一轮')).toBeInTheDocument(); fireEvent.change(screen.getByRole('combobox', { name: '消息发送方式' }), { target: { value: 'steer' } }); expect(onModeChange).toHaveBeenCalledWith('steer'); fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(onSubmit).toHaveBeenCalledOnce(); }); it('unlocks editing and offers recovery after the local Agent exits', async () => { const onRecover = vi.fn(); const { CodingComposer } = await import('@/pages/Chat/CodingComposer'); render( , ); expect(screen.getByRole('textbox')).toBeEnabled(); expect(screen.getByRole('alert')).toHaveTextContent('本地 Agent 已中断,原请求未自动重发。'); expect(screen.queryByText('当前对话正在处理。')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试' })); expect(onRecover).toHaveBeenCalledOnce(); }); it('answers product interactions and explains stale responses', async () => { interactionApi.respond.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('409 stale')); const { CodingInteractionPanel } = await import('@/pages/Chat/CodingInteractionPanel'); const onSettled = vi.fn(); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '方案 A' })); await waitFor(() => expect(interactionApi.respond).toHaveBeenCalledWith('conversation-1', { interactionId: 'interaction-select', optionId: 'option-a', })); expect(onSettled).toHaveBeenCalledOnce(); rerender( , ); fireEvent.change(screen.getByRole('textbox', { name: '输入名称的回答' }), { target: { value: 'Makelore' } }); fireEvent.click(screen.getByRole('button', { name: '提交回答' })); expect(await screen.findByText(/请求可能已失效/)).toBeInTheDocument(); }); it('keeps model, thinking, abort, metadata, and fork controls on the selected Conversation', async () => { const { useProviderStore } = await import('@/stores/providers'); useProviderStore.setState({ accounts: [{ id: 'account-1', vendorId: 'custom', label: 'Work account', authMode: 'api_key', model: 'model-a', fallbackModels: ['model-b'], enabled: true, isDefault: true, createdAt: '2026-08-24T00:00:00.000Z', updatedAt: '2026-08-24T00:00:00.000Z', }], vendors: [{ id: 'custom', name: 'Custom vendor' } as ProviderVendorInfo], }); interactionApi.abort.mockResolvedValue(undefined); interactionApi.model.mockResolvedValue({ model: null, modelResolution: 'required' }); interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' }); const callbacks = { rename: vi.fn(async () => undefined), archive: vi.fn(async () => undefined), unread: vi.fn(async () => undefined), fork: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), recover: vi.fn(async () => undefined), inspector: vi.fn(), }; const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader'); render( , ); fireEvent.change(screen.getByRole('combobox', { name: '当前对话模型' }), { target: { value: JSON.stringify(['account-1', 'model-b']) }, }); await waitFor(() => expect(interactionApi.model).toHaveBeenCalledWith('conversation-1', { accountId: 'account-1', modelId: 'model-b', thinkingLevel: 'high', })); await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce()); const thinkingSelect = screen.getByRole('combobox', { name: '当前对话思考级别' }); expect(within(thinkingSelect).getAllByRole('option')).toHaveLength(1); expect(within(thinkingSelect).getByRole('option', { name: '高思考' })).toBeInTheDocument(); expect(within(thinkingSelect).queryByRole('option', { name: '关闭思考' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '中止' })); await waitFor(() => expect(interactionApi.abort).toHaveBeenCalledWith('conversation-1')); fireEvent.click(screen.getByRole('button', { name: '创建分支' })); await waitFor(() => expect(callbacks.fork).toHaveBeenCalledOnce()); expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument(); }); it('does not let an old tools load overwrite the newly selected Conversation inspector', async () => { let resolveOld!: (value: { commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }) => void; const oldCommands = new Promise<{ commands: Array<{ name: string; title: string; description: string; source: 'makelore' }> }>((resolve) => { resolveOld = resolve; }); inspectorApi.changes.mockResolvedValue({ changes: null }); inspectorApi.files.mockResolvedValue({ files: [] }); inspectorApi.skills.mockResolvedValue({ skills: [] }); inspectorApi.commands.mockImplementation((conversationId: string) => ( conversationId === 'conversation-old' ? oldCommands : Promise.resolve({ commands: [{ name: 'new-command', title: 'New command', description: 'New', source: 'makelore' as const }] }) )); interactionApi.diagnostics.mockResolvedValue({ revision: { provider: 1, resources: 1 }, workers: [] }); const { CodingWorkspaceInspector } = await import('@/pages/Chat/CodingWorkspaceInspector'); const view = render( , ); await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-old')); view.rerender( , ); await waitFor(() => expect(inspectorApi.commands).toHaveBeenCalledWith('conversation-new')); await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()); const commandsTab = screen.getByRole('tab', { name: '命令' }); fireEvent.mouseDown(commandsTab, { button: 0, ctrlKey: false }); await waitFor(() => expect(commandsTab).toHaveAttribute('aria-selected', 'true')); expect(await screen.findByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); resolveOld({ commands: [{ name: 'old-command', title: 'Old command', description: 'Old', source: 'makelore' }] }); await oldCommands; await waitFor(() => expect(screen.queryByRole('button', { name: /\/old-command/ })).not.toBeInTheDocument()); expect(screen.getByRole('button', { name: /\/new-command/ })).toBeInTheDocument(); }); });