import { fireEvent, render, screen, waitFor } 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(), })); vi.mock('@/lib/coding-conversations', async (importOriginal) => ({ ...await importOriginal(), respondCodingConversationInteraction: interactionApi.respond, abortCodingConversation: interactionApi.abort, compactCodingConversation: interactionApi.compact, setCodingConversationModel: interactionApi.model, setCodingConversationThinking: interactionApi.thinking, })); 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('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: 'medium', })); await waitFor(() => expect(callbacks.refresh).toHaveBeenCalledOnce()); 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(); }); });