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, TeacherRequest, TeacherSend, 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(), updateDiscussion: vi.fn(), cancel: vi.fn(), save: vi.fn(), events: vi.fn(), })); vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, teacherTopicsPath: (p: string, s: string, role?: string) => p + '/' + (role ?? 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'); function request(overrides: Partial = {}): TeacherRequest { return { id: 'request-1', text: '老师帮我看看', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '', ...overrides, }; } const suggestedQuestions = ['怎样知道别人看懂了规则?', '我该先试哪个想法?']; function suggestionsRequest(overrides: Partial = {}): TeacherRequest { return request({ intent: 'suggestions', response: '我们可以从最近遇到的这两个地方聊起。', suggestedQuestions, ...overrides }); } let streams: Map }>; beforeEach(() => { vi.resetAllMocks(); localStorage.clear(); 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(); }); it('keeps friend suggestions as drafts until the student submits them', async () => { const role = 'friend'; const label = '朋友'; const suggestion = '想听听你对作品的第一印象'; const sendLabel = '发送给朋友'; api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.send.mockImplementation(async (_base, _id, input) => ({ ...first, requests: [{ id: input.requestId, text: input.text, references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '一起再想一想。', }] })); const view = render(); const sendButton = screen.getByRole('button', { name: sendLabel, exact: true }); await waitFor(() => expect(screen.getByLabelText(`向${label}提问`)).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: suggestion })); expect(screen.getByLabelText(`向${label}提问`)).toHaveValue(suggestion); expect(api.create).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); fireEvent.click(sendButton); await screen.findByText('一起再想一想。'); expect(api.config).toHaveBeenCalledWith(role); expect(api.send).toHaveBeenCalledWith(`p/${role}`, 'first', expect.objectContaining({ text: suggestion, sourceConversationId: 'c', references: [], })); expect(screen.getByLabelText(`向${label}提问`)).toHaveValue(''); view.unmount(); render(); expect(screen.getByLabelText(`向${label}提问`)).toHaveValue(''); }); it.each(['teacher', 'friend'] as const)('opens %s without an extra project row or a model request', async (role) => { api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: { ...definition, suggested_questions: ['固定的通用问题'] } }); render(); await waitFor(() => expect(screen.getByRole('textbox')).toBeEnabled()); expect(screen.queryByText('星星收集游戏')).not.toBeInTheDocument(); expect(screen.queryByText('当前项目')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); if (role === 'teacher') { expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled(); expect(screen.getByTestId('consultation-composer-actions')).toContainElement(screen.getByRole('button', { name: '老师帮我看看', exact: true })); expect(screen.queryByText('把你的困惑说出来就好')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '固定的通用问题' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '我还没想好下一步做什么' })).not.toBeInTheDocument(); } else { expect(screen.getByRole('button', { name: '固定的通用问题' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '老师帮我看看', exact: true })).not.toBeInTheDocument(); } }); it('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => { const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题'; api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...first, revision: 2, requests: [suggestionsRequest({ id: input.requestId, status: 'running', response: streamingJson, suggestedQuestions: undefined })], })); render(); const help = screen.getByRole('button', { name: '老师帮我看看', exact: true }); await waitFor(() => expect(help).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我自己还想问的另一件事' } }); fireEvent.click(help); await screen.findByText('我看看你最近做到了哪里…'); expect(screen.queryByText(streamingJson)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument(); expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({ intent: 'suggestions', text: '老师帮我看看', sourceConversationId: 'c', })); expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事'); expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeDisabled(); await waitFor(() => expect(streams.has('first')).toBe(true)); const sent = api.send.mock.calls[0][2] as TeacherSend; act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 3, requests: [suggestionsRequest({ id: sent.requestId })], }) }))); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument(); for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled(); expect(screen.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toBeEnabled(); expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled(); expect(api.send).toHaveBeenCalledTimes(1); expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事'); }); it.each([ [suggestedQuestions[0], undefined], ['我也说不清,你带我看看', 'guided-help'], ] as const)('sends the selected help action "%s" without replacing the student draft', async (text, intent) => { const previous = suggestionsRequest(); api.read.mockResolvedValue({ ...first, requests: [previous] }); api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, intent: input.intent, response: '我们先从你刚才做的那一步看起。' })], })); const view = render(); await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在整理的自由提问' } }); expect(api.send).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: text, exact: true })); await screen.findByText('我们先从你刚才做的那一步看起。'); expect(api.send).toHaveBeenCalledTimes(1); const sent = api.send.mock.calls[0][2] as TeacherSend; expect(sent).toMatchObject({ text, sourceConversationId: 'c' }); expect(sent.references).toEqual([]); expect(sent.intent).toBe(intent); expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问'); expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument(); view.unmount(); render(); expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问'); expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument(); expect(api.send).toHaveBeenCalledTimes(1); }); it('retries an uncertain suggestions request after reopening without losing the student draft', async () => { api.send.mockRejectedValue(new Error('网络暂不可用')); const view = render(); await waitFor(() => expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还没写完的问题' } }); fireEvent.click(screen.getByRole('button', { name: '老师帮我看看', exact: true })); await screen.findByText('网络暂不可用'); const originalRequest = api.send.mock.calls[0][2] as TeacherSend; expect(originalRequest.intent).toBe('suggestions'); view.unmount(); render(); await waitFor(() => expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled()); expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没写完的问题'); expect(api.send).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我又补了一点自己的想法' } }); fireEvent.click(screen.getByRole('button', { name: '老师帮我看看', exact: true })); await screen.findByText('网络暂不可用'); expect(api.send.mock.calls[1][2]).toEqual(originalRequest); expect(screen.getByLabelText('向老师提问')).toHaveValue('我又补了一点自己的想法'); }); it('shows a failed suggestion request without leaking raw JSON and starts a fresh request on another try', async () => { const failed = suggestionsRequest({ status: 'failed', response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' }); api.read.mockResolvedValue({ ...first, requests: [failed] }); api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...first, revision: 2, requests: [failed, suggestionsRequest({ id: input.requestId })], })); render(); await screen.findByText('这次没有看清楚,请再试一次。'); expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' }); expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id); }); it.each(['reopen', 'stream'] as const)('reconciles an accepted request learned through %s before retrying a failed reply', async (via) => { let accepted: TeacherRequest; api.send.mockRejectedValueOnce(new Error('响应中途断开')).mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })], })); const view = render(); await waitFor(() => expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled()); await waitFor(() => expect(streams.has('first')).toBe(true)); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我的自由提问继续留着' } }); fireEvent.click(screen.getByRole('button', { name: '老师帮我看看', exact: true })); await screen.findByText('响应中途断开'); const originalRequest = api.send.mock.calls[0][2] as TeacherSend; accepted = suggestionsRequest({ id: originalRequest.requestId, status: 'failed', response: '', suggestedQuestions: undefined, error: '老师已接到,但这次回复失败了' }); const recovered = { ...first, revision: 2, requests: [accepted] }; if (via === 'reopen') { view.unmount(); api.read.mockResolvedValue(recovered); render(); } else { act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) }))); } await screen.findByText('老师已接到,但这次回复失败了'); await waitFor(() => expect(screen.getByRole('button', { name: '再请老师看看', exact: true })).toBeEnabled()); expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着'); expect(api.send).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send).toHaveBeenCalledTimes(2); expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId); expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着'); }); it('still submits a student-written question with its quote and clears only that submitted draft', async () => { const quote = { kind: 'code' as const, text: 'setScore(score + 1)' }; api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...first, revision: 2, requests: [request({ id: input.requestId, text: input.text, references: input.references ?? [], response: '你觉得每点一次,分数应该怎么变化?', })], })); const view = render(); await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '为什么分数没有增加?' } }); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('你觉得每点一次,分数应该怎么变化?'); expect(api.send.mock.calls[0][2]).toMatchObject({ text: '为什么分数没有增加?', references: [quote], sourceConversationId: 'c' }); expect(api.send.mock.calls[0][2].intent).toBeUndefined(); expect(screen.getByLabelText('向老师提问')).toHaveValue(''); expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '老师帮我看看', exact: true })).toBeEnabled(); view.unmount(); render(); expect(screen.getByLabelText('向老师提问')).toHaveValue(''); expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument(); expect(api.send).toHaveBeenCalledTimes(1); }); it('restores a closed draft and keeps teacher, friend and project drafts separate', async () => { api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); const closed = vi.fn(); const view = render(); await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还没想清楚规则' } }); fireEvent.click(screen.getByRole('button', { name: '关闭老师' })); expect(closed).toHaveBeenCalledOnce(); view.rerender(<>); view.rerender(); expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没想清楚规则'); view.rerender(); await waitFor(() => expect(screen.getByLabelText('向朋友提问')).toBeEnabled()); expect(screen.getByLabelText('向朋友提问')).toHaveValue(''); fireEvent.change(screen.getByLabelText('向朋友提问'), { target: { value: '想听听你的感受' } }); view.rerender(); await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled()); expect(screen.getByLabelText('向老师提问')).toHaveValue(''); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '这个项目的另一个问题' } }); view.rerender(); expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没想清楚规则'); view.rerender(); expect(screen.getByLabelText('向朋友提问')).toHaveValue('想听听你的感受'); expect(api.send).not.toHaveBeenCalled(); expect(api.cancel).not.toHaveBeenCalled(); }); it('keeps a failed question and its request id when the consultation is reopened', async () => { api.send.mockRejectedValue(new Error('网络暂不可用')); const view = render(); await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled()); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我该怎样观察?' } }); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('网络暂不可用'); const originalRequest = api.send.mock.calls[0][2]; view.unmount(); render(); await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled()); expect(screen.getByLabelText('向老师提问')).toHaveValue('我该怎样观察?'); expect(api.send).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('网络暂不可用'); expect(api.send.mock.calls[1][2]).toEqual(originalRequest); }); it.each(['teacher', 'friend'] as const)('shows %s replies without redundant actions or importing advice', async (role) => { const bringBack = vi.fn(); api.read.mockResolvedValue({ ...first, requests: [{ id: 'reply', text: '游戏不好玩', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '先观察别人在哪儿停下来。', }] }); render(); await screen.findByText('先观察别人在哪儿停下来。'); expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument(); expect(screen.queryByText(/记一下|项目共识|记入共识|待办/)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '我去试一试' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '复制', exact: true })).not.toBeInTheDocument(); expect(bringBack).not.toHaveBeenCalled(); expect(api.send).not.toHaveBeenCalled(); }); it('keeps operations preview available without a consultation role', async () => { api.preview.mockResolvedValue({ payload: { ...definition, suggested_questions: ['运营配置的预览问题'] }, draft_revision: 7 }); api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null }); api.create.mockResolvedValue({ ...first, draftRevision: 7 }); render(); await waitFor(() => expect(screen.getByRole('button', { name: '老师新话题' })).toBeEnabled()); expect(screen.getByText('运营草稿试聊')).toBeInTheDocument(); expect(api.preview).toHaveBeenCalledWith(7); expect(api.config).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' })); expect(screen.getByLabelText('向老师提问')).toHaveValue('运营配置的预览问题'); expect(api.send).not.toHaveBeenCalled(); expect(screen.queryByRole('button', { name: '老师帮我看看', exact: true })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '老师新话题' })); await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码')); }); }); describe('pinned discussion workspace', () => { const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: { kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }], } }; async function openDiscussion(status: 'active' | 'offered' = 'active') { const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } }; api.read.mockResolvedValue(current); render(); await screen.findByText('一起想我的宠物游戏'); await waitFor(() => expect(streams.has('first')).toBe(true)); return current; } it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => { const current = await openDiscussion(); api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] })); fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true })); expect(api.send).not.toHaveBeenCalled(); expect(screen.getByText('接着聊:养只小狗')).toBeVisible(); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我希望是柯基' } }); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' }))); expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1); }); it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => { const current = await openDiscussion(); api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } }); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在想的下一句' } }); fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true })); await screen.findByText('先留在这里'); expect(screen.getByLabelText('向老师提问')).toHaveValue('还在想的下一句'); expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true'); api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] })); fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })); await screen.findByText('我们慢慢聊。'); expect(api.send.mock.calls[0][2].discussion).toBeUndefined(); api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } }); fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true })); await screen.findByRole('button', { name: '养只小狗', exact: true }); expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' }); }); it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => { const current = await openDiscussion('offered'); api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } }); expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true })); await screen.findByRole('button', { name: '把想法理一理', exact: true }); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '不要丢掉的草稿' } }); api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] }); fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true })); await waitFor(() => expect(api.send).toHaveBeenCalled()); expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' }); expect(screen.getByLabelText('向老师提问')).toHaveValue('不要丢掉的草稿'); }); it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => { const current = await openDiscussion(); const stream = streams.get('first')!; const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] }; act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) }))); expect(screen.getByRole('status')).toHaveTextContent('正在梳理'); expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible(); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '下一轮草稿' } }); api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] }); fireEvent.click(screen.getByRole('button', { name: '停止回复' })); await screen.findByText('这次先停在这里。想继续时可以再说说。'); expect(screen.getByLabelText('向老师提问')).toHaveValue('下一轮草稿'); act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) }))); expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1); expect(screen.getByText('一起想小狗和花园')).toBeVisible(); }); it('sends a normal suggested question directly without changing the composer draft', async () => { const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] }; api.read.mockResolvedValue(current); api.send.mockResolvedValue({ ...current, revision: 2 }); render(); await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' }); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己的草稿' } }); fireEvent.click(screen.getByRole('button', { name: '怎样知道小狗喜欢我?' })); await waitFor(() => expect(api.send).toHaveBeenCalled()); expect(api.send.mock.calls[0][2].text).toBe('怎样知道小狗喜欢我?'); expect(screen.getByLabelText('向老师提问')).toHaveValue('自己的草稿'); expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument(); }); }); it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => { const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: { kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }], } } }; api.read.mockResolvedValue(current); api.send.mockResolvedValue({ ...current, revision: 2 }); render(); await screen.findByText('回到家时'); fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '自己还没说完的话' } }); fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]); await waitFor(() => expect(api.send).toHaveBeenCalled()); expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } }); expect(screen.getByLabelText('向老师提问')).toHaveValue('自己还没说完的话'); expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1); });