Files
makelore/tests/unit/coding-teacher-ui.test.tsx
2026-09-29 11:47:43 +08:00

1011 lines
64 KiB
TypeScript

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(() => ({
catalog: vi.fn(),
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(),
conversation: vi.fn(),
sendConversation: vi.fn(),
seenConversation: vi.fn(),
}));
vi.mock('@/lib/coding-teacher', () => ({
teacherApi: api,
teacherTopicsPath: (p: string) => p === 'preview' ? 'preview/sample' : p + '/agent-topics',
legacyTopicBase: (base: string, legacy: boolean) => legacy ? base.replace('agent-topics', 'friend-topics') : base,
}));
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> = {}): 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> = {}): TeacherRequest {
return request({ intent: 'suggestions', response: '我们可以从最近遇到的这两个地方聊起。', suggestedQuestions, ...overrides });
}
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
streams = new Map();
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'one', version: 1, definition, is_default: true }] });
api.conversation.mockResolvedValue({ topic: null, before: null });
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 screen.findByRole('button', { name: '以往讨论' });
if (!screen.queryByLabelText('智能体话题')) fireEvent.click(screen.getByRole('button', { name: '以往讨论' }));
await waitFor(() =>
expect(screen.getByRole('combobox', { name: '智能体话题' })).toHaveValue('first')
);
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('opens directly into the consultation and puts new topics inside expanded history', async () => {
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const history = await screen.findByRole('button', { name: '以往讨论' });
const panel = screen.getByRole('complementary', { name: '智能体咨询' });
expect(panel).toBeVisible();
expect(panel.querySelector('header')).toBeNull();
expect(screen.queryByText('服务端简介')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
fireEvent.click(history);
const newTopic = screen.getByRole('button', { name: '智能体新话题' });
expect(newTopic).toBeEnabled();
expect(newTopic).toHaveTextContent('新话题');
fireEvent.click(newTopic);
await waitFor(() => expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 1));
expect(api.send).not.toHaveBeenCalled();
});
it('renders the pinned topic icon with assistant messages', async () => {
const icon = 'https://yuxi.example.test/api/storage/public/images/topic.png';
api.read.mockResolvedValue({ ...first, definition: { ...definition, icon_url: icon }, requests: [request({ response: '继续原来的讨论' })] });
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const images = view.container.querySelectorAll('[data-testid="teacher-chat-panel"] img');
expect(images).toHaveLength(1);
for (const img of images) expect(img).toHaveAttribute('src', icon);
});
it('renders assistant Markdown, tables, code and images instead of literal markup', async () => {
const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n![项目截图](https://example.com/preview.png)';
api.read.mockResolvedValue({ ...first, requests: [request({ response })] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '项目建议' })).toBeVisible();
expect(screen.getByRole('table')).toHaveTextContent('main.ts');
expect(screen.getByText('保留现有玩法').closest('li')).not.toBeNull();
expect(screen.getByText('{"name":"flybot"}').closest('pre')).not.toBeNull();
expect(screen.getByRole('link', { name: '说明' })).toHaveAttribute('href', 'https://example.com/readme');
expect(screen.getByRole('img', { name: '项目截图' })).toHaveAttribute('src', 'https://example.com/preview.png');
});
it('does not render a zero for an empty legacy friend draft', async () => {
localStorage.setItem('makelore-consultation-draft:signed-out:p:friend', JSON.stringify({ text: '', references: [] }));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const composer = screen.getByLabelText('向智能体提问').closest('form');
expect(composer).not.toHaveTextContent(/^0/);
expect(screen.queryByText('查看旧版朋友草稿')).not.toBeInTheDocument();
});
it('keeps malformed originals folded and literal while rendering the recovered answer', async () => {
const raw = '{"reply":"正文已保留","tool":<script>alert(1)</script>';
api.read.mockResolvedValue({ ...first, requests: [request({
response: '**正文已保留**', discussionError: '这次整理没有完成,先保留原来的内容。', unparsedResponse: raw,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText('正文已保留').tagName).toBe('STRONG');
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(details?.querySelector('script')).toBeNull();
});
it('marks an incomplete completed reply instead of displaying a half sentence as a finished answer', async () => {
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——还有后面的回答';
const bringBack = vi.fn();
api.read.mockResolvedValue({ ...first, requests: [request({
text: '现在项目是什么阶段?', response: '它早就不是', replyIncomplete: true,
replyParseError: 'format error', unparsedResponse: raw, suggestedQuestions: ['尚未确认的建议'],
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
await ready();
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
expect(screen.queryByTestId('teacher-reply')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的建议' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新回答' })).toBeEnabled();
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(bringBack).not.toHaveBeenCalled();
});
it('keeps complete Markdown visible when only auxiliary fields could not be recovered', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '**它早就不是"刚搭好架子"那种阶段了。** 后面的回答也已经收到。',
replyParseError: 'invalid quickReplies', unparsedResponse: '{"reply":"完整回答","quickReplies":',
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('它早就不是"刚搭好架子"那种阶段了。 后面的回答也已经收到。');
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
expect(screen.queryByText('这次回复未能完整显示')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '重新回答' })).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
});
it.each(['question', 'suggestions', 'guided-help'] as const)('retries an incomplete %s only on click with its original input while preserving the current draft', async intent => {
const originalReferences = [{ messageId: 'original-message', text: '原来的引用', role: 'assistant' as const }];
const currentQuote = { messageId: 'current-message', text: '草稿里的新引用', role: 'assistant' as const };
const incomplete = request({
intent, text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: originalReferences,
response: '它早就不是', replyIncomplete: true,
});
const before = { ...first, requests: [incomplete] };
api.read.mockResolvedValue(before);
let finish!: (topic: TeacherTopic) => void;
api.send.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
render(<TeacherChatPanel projectId="p" sourceId="different-source" quote={currentQuote} />);
await ready();
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '还没说完的新想法' } });
expect(api.send).not.toHaveBeenCalled();
const retry = screen.getByRole('button', { name: '重新回答' });
fireEvent.click(retry);
fireEvent.click(retry);
expect(api.send).toHaveBeenCalledTimes(1);
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent).toEqual({ requestId: expect.any(String), text: incomplete.text, intent, references: originalReferences, sourceConversationId: 'original-source' });
expect(sent.requestId).not.toBe(incomplete.id);
expect(retry).toBeDisabled();
fireEvent.change(input, { target: { value: '等待时又补充了想法' } });
await act(async () => finish({ ...before, revision: 2, requests: [...before.requests, request({ id: sent.requestId, text: sent.text, response: '这次的完整回答。' })] }));
expect(screen.getByText('这次的完整回答。')).toBeVisible();
expect(input).toHaveValue('等待时又补充了想法');
expect(screen.getByText('草稿里的新引用')).toBeVisible();
expect(JSON.parse(localStorage.getItem('makelore-consultation-draft:signed-out:p:teacher')!)).toMatchObject({ text: '等待时又补充了想法', references: [currentQuote] });
});
it('keeps the retry id and draft after an uncertain send failure and uses the original topic source for older replies', async () => {
const incomplete = request({ text: '原来的问题', response: '', replyIncomplete: true });
const before = { ...first, sourceConversationId: 'older-source', requests: [incomplete] };
api.read.mockResolvedValue(before);
api.send.mockRejectedValueOnce(new Error('暂时没连上')).mockImplementation(async (_base, _id, sent: TeacherSend) => ({
...before, revision: 2, requests: [incomplete, request({ id: sent.requestId, text: sent.text, response: '完整回答' })],
}));
render(<TeacherChatPanel projectId="p" sourceId="new-source" />);
await ready();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '草稿不能丢' } });
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await screen.findByRole('alert');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await screen.findByText('完整回答');
expect(api.send).toHaveBeenCalledTimes(2);
expect(api.send.mock.calls[0][2]).toEqual(api.send.mock.calls[1][2]);
expect(api.send.mock.calls[1][2]).toMatchObject({ text: '原来的问题', sourceConversationId: 'older-source', references: [] });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
});
it('retries a project-only historical question without attaching another conversation or the project sentinel', async () => {
const incomplete = request({ text: '原来的问题', replyIncomplete: true });
const before = { ...first, sourceConversationId: 'project', requests: [incomplete] };
api.read.mockResolvedValue(before);
api.send.mockResolvedValue(before);
render(<TeacherChatPanel projectId="p" sourceId="different-source" />);
await ready();
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
expect(api.send.mock.calls[0][2]).not.toHaveProperty('sourceConversationId');
});
it('does not retry an incomplete reply from a disabled Agent or turn a check-in into a normal question', async () => {
const archived = { ...first, definition: { ...definition, config_id: 'unavailable-agent' }, requests: [
request({ response: '', replyIncomplete: true }),
request({ id: 'check-in', intent: 'check-in', text: '', response: '', replyIncomplete: true }),
] };
api.read.mockResolvedValue(archived);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getAllByText('这次回复未能完整显示')).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '重新回答' })).toHaveLength(1);
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
expect(api.send).not.toHaveBeenCalled();
});
it('shows the format error alongside the preserved reply while keeping excess shortcuts noninteractive', async () => {
const response = '先看看摸头会带来什么变化。';
const quickReplies = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。'];
const replyParseError = '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
const raw = '\n' + JSON.stringify({ reply: response, quickReplies }, null, 2) + '\n';
api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({
presentation: 'reply-v1', response, suggestedQuestions: [], replyParseError, unparsedResponse: raw,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText(response)).toBeVisible();
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
for (const shortcut of quickReplies) {
expect(screen.queryByRole('button', { name: shortcut, exact: true })).not.toBeInTheDocument();
}
const details = screen.getByText('查看收到的原始内容').closest('details');
expect(details).not.toHaveAttribute('open');
expect(details?.querySelector('pre')?.textContent).toBe(raw);
expect(screen.getByLabelText('向智能体提问')).toBeEnabled();
expect(api.send).not.toHaveBeenCalled();
});
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n![本地图片](file:///C:/secret.png)\n\n![坏图片](https://example.com/missing.png)',
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const body = screen.getByTestId('teacher-reply');
expect(body.querySelector('.katex')).not.toBeNull();
expect(body.querySelector('pre')?.textContent).toContain('const value = "\\n";');
expect(body.querySelector('script')).toBeNull();
expect(screen.queryByRole('link', { name: '危险' })).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '本地图片' })).not.toBeInTheDocument();
fireEvent.error(screen.getByRole('img', { name: '坏图片' }));
expect(screen.getByText('[图片未能显示:坏图片]')).toBeVisible();
});
it('updates streamed Markdown without duplicating the final answer', async () => {
const running = { ...first, requests: [request({ status: 'running', response: '## 正在回答\n\n**先检查' })] };
api.read.mockResolvedValue(running);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByRole('heading', { name: '正在回答' })).toBeVisible();
const completed = { ...running, revision: 2, requests: [request({ response: '## 正在回答\n\n**先检查文件**' })] };
await act(async () => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(completed) })));
expect(screen.getAllByTestId('teacher-reply')).toHaveLength(1);
expect(screen.getByText('先检查文件').tagName).toBe('STRONG');
});
it('keeps tool activity separate from the answer and stops stale running indicators', async () => {
api.read.mockResolvedValue({ ...first, requests: [request({
response: '已经看过项目文件。',
toolActivity: [
{ id: 'one', name: 'read_project_file', status: 'completed' },
{ id: 'two', name: 'read_conversation', status: 'failed' },
{ id: 'three', name: 'cloud-search', status: 'running' },
],
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const details = screen.getByText('使用了 3 项工具').closest('details')!;
expect(details).not.toHaveAttribute('open');
expect(details).toHaveTextContent('读取项目文件已完成');
expect(details).toHaveTextContent('读取会话原文未完成');
expect(details).toHaveTextContent('未收到执行结果');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('已经看过项目文件。');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('读取会话原文');
});
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,
truncatedMessages: 1,
status: 'completed',
response: '教学回答',
},
],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
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(screen.queryByText('服务端简介')).not.toBeInTheDocument();
expect(screen.getByText('较长的上下文已节选,智能体可按需读取原文。')).toBeVisible();
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 the panel unmounts', async () => {
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
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');
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(<TeacherChatPanel projectId="p" sourceId="c" />);
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 label = '智能体';
const suggestion = '想听听你对作品的第一印象';
const sendLabel = '提问';
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'experience', version: 1, is_default: true, definition: { ...definition, name: '体验伙伴', suggested_questions: [suggestion] } }] });
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(<TeacherChatPanel projectId="p" sourceId="c" />);
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();
expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({
text: suggestion, sourceConversationId: 'c', references: [],
}));
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
});
it.each(['方法老师', '朋友', '代码顾问'])('renders the published identity %s without creating a role or model request', async name => {
const published = { ...definition, name, avatar_id: 'avatar-09', description: '这是运营发布的简介', welcome_message: '服务端欢迎语', suggested_questions: ['服务端推荐问题'] };
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'published', version: 2, is_default: true, definition: published }] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const panel = await screen.findByRole('complementary', { name: `${name}咨询`, exact: true });
expect(screen.queryByText('这是运营发布的简介')).not.toBeInTheDocument();
expect(panel.querySelector('header')).toBeNull();
expect(screen.getByText('服务端欢迎语')).toBeVisible();
expect(screen.getByRole('button', { name: '服务端推荐问题' })).toBeEnabled();
expect(screen.getByTestId('teacher-chat-panel').querySelector('img')?.src).toContain('avatar-09.png');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
});
it.each(['question', 'suggestions'] as const)('keeps duplicate %s quick cards distinct and clickable without replacing the draft', async intent => {
const question = '我可以先试哪一步?';
const previous = request({ intent, response: '先从你想尝试的地方聊起。', suggestedQuestions: [question, question] });
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, response: '我们就聊这一点。' })],
}));
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const cards = await screen.findAllByRole('button', { name: question, exact: true });
expect(cards).toHaveLength(2);
expect(cards[0]).toBeEnabled();
expect(cards[1]).toBeEnabled();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还没说完的想法' } });
fireEvent.click(cards[1]);
await screen.findByText('我们就聊这一点。');
expect(api.send).toHaveBeenCalledTimes(1);
expect(api.send.mock.calls[0][2]).toEqual({ requestId: expect.any(String), text: question, references: [], sourceConversationId: 'c' });
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还没说完的想法');
expect(screen.getAllByRole('button', { name: question, exact: true })).toHaveLength(2);
expect(errors.mock.calls.filter(args => /same key|unique.*key/i.test(args.join(' ')))).toEqual([]);
} finally {
errors.mockRestore();
}
});
it('only requests suggestions from the empty composer and preserves a later draft while streaming', 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(<TeacherChatPanel projectId="p" sourceId="c" />);
const input = screen.getByLabelText('向智能体提问');
const help = () => screen.getByText('帮我看看', { selector: 'form .consultation-hint' });
await waitFor(() => expect(help()).toBeEnabled());
expect(screen.getByTestId('consultation-composer-actions')).toContainElement(help());
expect(help()).toHaveAccessibleName('帮我看看');
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '我先自己想想' } });
expect(screen.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: ' ' } });
expect(help()).toBeEnabled();
fireEvent.change(input, { target: { value: '' } });
expect(help()).toBeEnabled();
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(help());
fireEvent.click(help());
await screen.findByText('我看看你最近做到了哪里…');
expect(screen.queryByText(streamingJson)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({
intent: 'suggestions', text: '帮我看看', sourceConversationId: 'c',
}));
expect(help()).toBeDisabled();
fireEvent.change(input, { target: { value: '我自己还想问的另一件事' } });
expect(screen.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
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.queryByRole('button', { name: '我也说不清,你带我看看', exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(input).toHaveValue('我自己还想问的另一件事');
});
it.each([suggestedQuestions[0], '我也说不清,你带我看看'])('sends the Agent-generated card "%s" as an ordinary question without replacing the draft', async text => {
const previous = suggestionsRequest({ suggestedQuestions: [text] });
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, response: '我们先从你刚才做的那一步看起。' })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" quote={{ kind: 'code', text: '学生正在引用的一段代码' }} />);
await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled());
expect(screen.getAllByRole('button', { name: text, exact: true })).toHaveLength(1);
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).toBeUndefined();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('keeps an uncertain suggestions request identity through reopening and draft edits', async () => {
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled());
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } });
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
expect(originalRequest.intent).toBe('suggestions');
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没写完的问题');
expect(screen.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
});
it.each([{ text: '帮我看看', references: undefined }, { text: '给我一点提示', references: [{ kind: 'code' as const, text: '旧请求的引用' }] }])('retries an uncertain legacy hint payload "$text" unchanged with references $references', async ({ text, references }) => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text, intent: 'suggestions', sourceConversationId: 'previous-source', ...(references ? { references } : {}) };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original }));
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[0][2]))).toEqual(original);
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[1][2]))).toEqual(original);
});
it('does not reuse an uncertain legacy hint request for an ordinary typed question with the same text', async () => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text: '帮我看看', intent: 'suggestions', references: [] };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: original.text, references: [], pending: original }));
api.send.mockResolvedValue({ ...first, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent.requestId).not.toBe(original.requestId);
expect(sent.text).toBe(original.text);
expect(sent.intent).toBeUndefined();
});
it.each(['completed', 'failed'] as const)('starts a fresh hint when the old hint request is already confirmed %s', async status => {
const original: TeacherSend = { requestId: 'legacy-hint-request', text: '帮我看看', intent: 'suggestions' };
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original }));
api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ id: original.requestId, text: original.text, status })] });
api.send.mockResolvedValue({ ...first, revision: 2 });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent.requestId).not.toBe(original.requestId);
expect(sent).toMatchObject({ text: '帮我看看', intent: 'suggestions', references: [], sourceConversationId: 'c' });
});
it.each(['failed', 'cancelled', 'interrupted'] as const)('retries a %s suggestion request without leaking raw JSON or reusing its accepted request id', async status => {
const failed = suggestionsRequest({ status, 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(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByText('这次没有看清楚,请再试一次。');
expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument();
fireEvent.click(screen.getByText('帮我看看', { selector: '.consultation-retry' }));
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(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
fireEvent.click(screen.getByText('帮我看看', { selector: 'form .consultation-hint' }));
await screen.findByText('响应中途断开');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } });
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(<TeacherChatPanel projectId="p" sourceId="c" />);
} else {
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) })));
}
await screen.findByText('智能体已接到,但这次回复失败了');
await waitFor(() => expect(screen.getByText('帮我看看', { selector: '.consultation-retry' })).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('帮我看看', { selector: '.consultation-retry' }));
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(<TeacherChatPanel projectId="p" sourceId="c" quote={quote} />);
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.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeEnabled();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('keeps drafts project-scoped across operation sessions and reopening', async () => {
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '保留我的草稿' } });
view.rerender(<TeacherChatPanel key="other" projectId="other" sourceId="c" />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
view.rerender(<TeacherChatPanel key="p-again" projectId="p" sourceId="another" />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('保留我的草稿');
expect(api.send).not.toHaveBeenCalled();
});
it('retains existing project drafts and exposes old friend text without rebinding or submitting it', async () => {
const { useAuthStore } = await import('@/stores/auth');
const user = useAuthStore.getState().user;
const account = user?.userId ?? user?.username ?? 'signed-out';
const prefix = `makelore-consultation-draft:${account}:p:`;
const legacy = JSON.stringify({ text: '旧朋友的未发送草稿', references: [{ kind: 'code', text: 'oldCode()' }], pending: { requestId: 'old-request' } });
localStorage.setItem(prefix + 'teacher', JSON.stringify({ text: '原有项目草稿' }));
localStorage.setItem(prefix + 'friend', legacy);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('原有项目草稿');
fireEvent.click(screen.getByText('查看旧版朋友草稿'));
expect(screen.getByText('旧朋友的未发送草稿')).toBeVisible();
expect(screen.getByText('oldCode()')).toBeVisible();
expect(localStorage.getItem(prefix + 'friend')).toBe(legacy);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).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(<TeacherChatPanel projectId="p" sourceId="c" />);
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(<TeacherChatPanel projectId="p" sourceId="c" />);
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('shows replies without automatically importing advice', async () => {
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(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
await screen.findByText('先观察别人在哪儿停下来。');
expect(screen.getByRole('button', { name: '带回主会话草稿' })).toBeEnabled();
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(<TeacherChatPanel projectId="preview" sourceId="sample" draftRevision={7} sampleContext="学生的练习代码" />);
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.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '智能体新话题' }));
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码', undefined));
});
});
describe('legacy discussion history compatibility', () => {
it('shows every historical shortcut even when the old reply has more than three', async () => {
const shortcuts = ['看懂规则|摸头会改变什么?', '想想作用|我们聊聊摸头。', '试试结果|怎样看出变化?', '换个话题|我想聊别的地方。'];
api.read.mockResolvedValue({ ...first, requests: [request({
presentation: 'discussion-v1', response: '这些是之前留下的讨论入口。', suggestedQuestions: shortcuts,
})] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
expect(screen.getByText('这些是之前留下的讨论入口。')).toBeVisible();
for (const shortcut of shortcuts) {
expect(screen.getByRole('button', { name: shortcut, exact: true })).toBeEnabled();
}
expect(screen.queryByText('查看收到的原始内容')).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
});
const contents = [
{ kind: 'ideas', title: '旧想法组件', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] },
{ kind: 'structure', title: '旧结构组件', nodes: [{ id: 'dog', label: '照顾小狗' }] },
{ kind: 'flow', title: '旧流程组件', nodes: [{ id: 'home', kind: 'event', label: '我回到家' }], edges: [] },
{ kind: 'comparison', title: '旧对比组件', columns: [{ id: 'a', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '跑来欢迎我' }] }] },
];
const histories = contents.flatMap(content => ['active', 'offered', 'paused', 'finished'].map(status => ({ kind: content.kind, status, content })));
it.each(histories)('reads $kind / $status history as ordinary replies with working quick cards and free input', async ({ status, content }) => {
const current = { ...structuredClone(first), discussion: { id: 'legacy-tool', revision: 3, status, content }, requests: [request({
presentation: 'discussion-v1', discussionSnapshot: content, discussionContext: { toolId: 'legacy-tool', revision: 3 },
response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'],
})] } as TeacherTopic;
api.read.mockResolvedValue(current);
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...current, revision: current.revision + 1, requests: [...current.requests, request({ id: input.requestId, text: input.text, response: '我们就从这个问题聊起。' })] }));
render(<TeacherChatPanel projectId="p" sourceId="c" />);
const card = await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
expect(screen.getByText('先听听你的想法。')).toBeVisible();
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
expect(screen.queryByText(content.title)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /用这个一起想|先这些|接着改|把想法理一理|聊聊这一点|取消讨论焦点/ })).not.toBeInTheDocument();
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '自己的草稿' } });
fireEvent.click(card);
await screen.findByText('我们就从这个问题聊起。');
expect(api.send.mock.calls[0][2]).toEqual({ requestId: expect.any(String), text: '怎样知道小狗喜欢我?', references: [], sourceConversationId: 'c' });
expect(input).toHaveValue('自己的草稿');
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(2));
expect(api.send.mock.calls[1][2]).toEqual({ requestId: expect.any(String), text: '自己的草稿', references: [], sourceConversationId: 'c' });
await waitFor(() => expect(input).toHaveValue(''));
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
});
it('keeps live text and cancellation working when snapshots still contain legacy component fields', async () => {
const current = { ...structuredClone(first), discussion: { id: 'tool', revision: 1, status: 'active', content: contents[0] } } as TeacherTopic;
api.read.mockResolvedValue(current);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
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.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
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: { unknownLegacyShape: true }, requests: [request({ response: '旧历史继续显示普通回复。', suggestedQuestions: ['接着问一个问题'] })] }) })));
expect(screen.getByText('旧历史继续显示普通回复。')).toBeVisible();
expect(screen.getByRole('button', { name: '接着问一个问题' })).toBeEnabled();
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
});
it('removes retired fields from a persisted pending send without changing its draft, references or retry id', async () => {
const key = 'makelore-consultation-draft:signed-out:p:teacher';
const references = [{ kind: 'code', text: 'const pet = "dog";' }];
const original = { requestId: 'pending-before-component-removal', text: '继续聊这件事', references, intent: 'question', sourceConversationId: 'previous-source' };
localStorage.setItem(key, JSON.stringify({ text: original.text, references, pending: { ...original, presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 2, focusId: 'dog', transition: 'structure' } } }));
api.send.mockRejectedValueOnce(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue(original.text);
expect(screen.getByText(references[0].text)).toBeVisible();
expect(JSON.parse(localStorage.getItem(key)!).pending).toEqual(original);
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[0][2]).toEqual(original);
view.unmount();
api.send.mockResolvedValue({ ...first, revision: 2, requests: [request({ id: original.requestId, text: original.text, response: '可以,我们继续。' })] });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByRole('button', { name: '提问', exact: true })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('可以,我们继续。');
expect(api.send.mock.calls[1][2]).toEqual(original);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.queryByText(references[0].text)).not.toBeInTheDocument();
});
});
describe('merged cloud teacher classroom', () => {
it.each([false, true])('retains an uncertain hint identity and project across continuous-chat upgrades (scoped: %s)', async scoped => {
const teacherId = `pending-agent-${scoped}`;
const published = { ...definition, config_id: teacherId };
const agent = { teacher_id: teacherId, version: 2, definition: published, is_default: true };
const original: TeacherSend = {
requestId: 'uncertain-before-upgrade', text: scoped ? '给我一点提示' : '帮我看看',
intent: 'suggestions', sourceConversationId: 'original-source',
...(scoped ? { projectId: 'p' } : {}),
};
const key = scoped ? `makelore-consultation-draft:signed-out:agent:${teacherId}`
: `makelore-consultation-draft:signed-out:p:agent:${teacherId}`;
localStorage.setItem(key, JSON.stringify({ text: '', references: [], pending: {
...original, presentation: 'discussion-v1', discussion: { toolId: 'retired', revision: 1 },
} }));
api.catalog.mockResolvedValue({ items: [agent] });
api.sendConversation.mockRejectedValue(new Error('结果暂未确认'));
const props = { projectId: 'p', sourceId: 'original-source', selectedAgent: agent };
const view = render(<TeacherChatPanel {...props} />);
const hint = () => screen.getByText('帮我看看', { selector: 'form .consultation-hint' });
await waitFor(() => expect(hint()).toBeEnabled());
expect(api.sendConversation).not.toHaveBeenCalled();
fireEvent.click(hint());
await screen.findByText('结果暂未确认');
expect(api.sendConversation).toHaveBeenCalledWith(teacherId, { ...original, projectId: 'p' });
view.rerender(<TeacherChatPanel {...props} projectId="another-project" />);
fireEvent.click(hint());
await screen.findByText('上次提问仍绑定原项目,请切回原项目确认结果后重试。');
expect(api.sendConversation).toHaveBeenCalledTimes(1);
view.rerender(<TeacherChatPanel {...props} sourceId="another-source" />);
fireEvent.click(hint());
await screen.findByText('上次提问仍绑定原操作对话,请切回原操作对话确认结果后重试。');
expect(api.sendConversation).toHaveBeenCalledTimes(1);
view.rerender(<TeacherChatPanel {...props} />);
fireEvent.click(hint());
await screen.findByText('结果暂未确认');
expect(api.sendConversation).toHaveBeenCalledTimes(2);
expect(api.sendConversation.mock.calls[1][1]).toEqual({ ...original, projectId: 'p' });
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
});
it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => {
const published = { ...definition, config_id: 'one' };
const original = JSON.stringify({ text: '旧项目草稿', references: [{ kind: 'code', text: '旧引用' }] });
const key = 'makelore-consultation-draft:signed-out:p:teacher';
localStorage.setItem(key, original);
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'one', version: 1, definition: published, is_default: true }} />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
fireEvent.click(screen.getByText('查看之前的项目草稿'));
expect(screen.getByText('旧项目草稿')).toBeVisible();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '新的独立草稿' } });
expect(localStorage.getItem(key)).toBe(original);
expect(api.send).not.toHaveBeenCalled();
});
it('selects a cloud teacher for the first project discussion without sending the operation draft', async () => {
const cloudDefinition = { ...definition, runtime: 'yuxi' as const, config_id: 'algorithm', name: '算法智能体', yuxi: { agent_slug: 'algorithm', agent_version: 2 } };
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.catalog.mockResolvedValue({ items: [
{ teacher_id: 'one', version: 1, definition, is_default: true },
{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false },
] });
const selected = { ...topic('selected'), sourceConversationId: 'project', role: 'teacher' as const, definition: cloudDefinition, version: 9 };
api.conversation.mockResolvedValue({ topic: null, before: null });
api.create.mockResolvedValue(selected);
api.sendConversation.mockImplementation(async (_agent, input: TeacherSend) => ({ ...selected, revision: 2,
conversation: {agentId: 'algorithm', segmentTurns: 1, discussions: {}, seenThrough: input.requestId},
requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={{ teacher_id: 'algorithm', version: 9, definition: cloudDefinition, is_default: false }} />);
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '以往讨论' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('先看看玩家会做什么。');
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).toHaveBeenCalledWith('algorithm', {
requestId: expect.any(String), text: '我想做一个小游戏', references: [], projectId: 'p', sourceConversationId: 'c',
});
expect(screen.getByRole('complementary', { name: '算法智能体咨询' })).toBeVisible();
});
it('does not enable a disabled cloud topic through another teacher or an Enter shortcut', async () => {
api.read.mockResolvedValue({ ...first, definition: { ...definition, config_id: 'disabled-teacher', runtime: 'yuxi', yuxi: { agent_slug: 'old', agent_version: 1 } } });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await waitFor(() => expect(screen.getByText('智能体暂未开放,历史仍可查看。')).toBeVisible());
const input = screen.getByLabelText('向智能体提问');
expect(screen.getByText('帮我看看', { selector: 'form .consultation-hint' })).toBeDisabled();
fireEvent.change(input, { target: { value: '继续讨论' } });
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
expect(screen.queryByText('帮我看看', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(api.send).not.toHaveBeenCalled();
});
it('keeps old locally-generated friend history readable without allowing another turn', async () => {
const legacy = { ...first, role: 'friend' as const, definition: { ...definition, name: '旧朋友' } };
api.list.mockResolvedValue({ items: [{ id: 'first', title: '旧朋友历史', legacyRole: 'friend' }], lastSelectedTopicId: null });
api.read.mockResolvedValue(legacy);
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByRole('button', { name: '以往讨论' });
fireEvent.click(screen.getByRole('button', { name: '以往讨论' }));
fireEvent.change(screen.getByLabelText('智能体话题'), { target: { value: 'first' } });
await screen.findByText('这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。');
expect(api.read).toHaveBeenCalledWith('p/friend-topics', 'first');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续' } });
fireEvent.keyDown(screen.getByLabelText('向智能体提问'), { key: 'Enter' });
expect(api.send).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '智能体新话题' })).toBeEnabled();
});
});