import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher'; const api = vi.hoisted(() => ({ config: vi.fn(), preview: vi.fn(), list: vi.fn(), create: vi.fn(), read: vi.fn(), send: vi.fn(), cancel: vi.fn(), save: vi.fn(), events: vi.fn(), })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, teacherTopicsPath: (p: string, s: string) => p + '/' + s, })); const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '老师', description: '', avatar_id: 'avatar-01', welcome_message: '欢迎', suggested_questions: [], system_prompt: '教学', skills: [], model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 }, }; function topic(id: string): TeacherTopic { return { schemaVersion: 1, revision: 1, id, accountId: 'user', projectId: 'p', sourceConversationId: 'c', definition, version: 1, createdAt: '2026-09-22', updatedAt: '2026-09-22', requests: [], }; } const first = topic('first'), second = topic('second'); let streams: Map }>; beforeEach(() => { vi.resetAllMocks(); streams = new Map(); api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition }); api.list.mockResolvedValue({ items: [ { id: 'first', title: '第一个' }, { id: 'second', title: '第二个' }, ], lastSelectedTopicId: 'first', }); api.read.mockImplementation(async (_base, id) => id === 'first' ? structuredClone(first) : structuredClone(second) ); api.create.mockResolvedValue(first); api.events.mockImplementation(async (_base, id) => { const stream = Object.assign(new EventTarget(), { close: vi.fn() }); streams.set(id, stream); return stream; }); }); async function ready() { await waitFor(() => expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('first') ); await waitFor(() => expect(streams.has('first')).toBe(true)); } describe('teacher side chat', () => { it('retains failed input and retries with the same request id, then appends a reply only on click', async () => { const bringBack = vi.fn(); api.send .mockRejectedValueOnce(new Error('网络暂不可用')) .mockImplementationOnce(async (_base, _id, input) => ({ ...first, revision: 3, requests: [ { id: input.requestId, text: input.text, references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '教学回答', }, ], })); render(); await ready(); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '解释代码' } }); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('网络暂不可用'); expect(screen.getByLabelText('向老师提问')).toHaveValue('解释代码'); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('教学回答'); expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId); expect(bringBack).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' })); expect(bringBack).toHaveBeenCalledWith('教学回答'); }); it('ignores an old topic stream after switching and does not cancel when closed', async () => { const closed = vi.fn(); const view = render(); await ready(); const old = streams.get('first')!; fireEvent.change(screen.getByRole('combobox', { name: '老师话题' }), { target: { value: 'second' }, }); await waitFor(() => expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second') ); act(() => old.dispatchEvent( new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 999 }) }) ) ); expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second'); fireEvent.click(screen.getByRole('button', { name: '关闭老师' })); expect(closed).toHaveBeenCalledOnce(); view.unmount(); expect(api.cancel).not.toHaveBeenCalled(); expect(old.close).toHaveBeenCalled(); }); it('shows disabled history while blocking a new question', async () => { api.config.mockResolvedValue({ enabled: false, definition }); render(); await ready(); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '问题' } }); expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled(); expect(api.read).toHaveBeenCalled(); }); });