Files
makelore/tests/unit/coding-teacher-ui.test.tsx
2026-09-29 20:06:31 +08:00

1080 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(() => ({
models: vi.fn(),
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.models.mockResolvedValue({ supported: true, models: [{ id: 'qwen' }, { id: 'other-model' }] });
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('renders published shortcut labels in order and sends each actual prompt with independent retry identity', async () => {
const shortcuts = [{ label: '看看进展', prompt: '请分析当前项目的进展。' }, { label: '解释代码', prompt: '请解释刚才的代码改动。' }];
const agent = { teacher_id: 'shortcuts', version: 1, is_default: true, definition: { ...definition, config_id: 'shortcuts', shortcuts } };
api.catalog.mockResolvedValue({ items: [agent] });
api.sendConversation.mockRejectedValue(new Error('结果暂未确认'));
render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={agent} />);
await waitFor(() => expect(screen.getByRole('button', { name: '看看进展' })).toBeEnabled());
expect([...screen.getByTestId('consultation-shortcuts').querySelectorAll('.consultation-hint')].map(el => el.textContent)).toEqual(shortcuts.map(item => item.label));
expect(screen.queryByRole('button', { name: '帮我整体看🧠' })).not.toBeInTheDocument();
expect(screen.queryByText(shortcuts[0].prompt)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '看看进展' }));
await screen.findByText('结果暂未确认');
const original = api.sendConversation.mock.calls[0][1];
expect(original).toMatchObject({ text: shortcuts[0].prompt, projectId: 'p', sourceConversationId: 'c', references: [] });
expect(original.intent).toBeUndefined();
fireEvent.click(screen.getByRole('button', { name: '看看进展' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole('button', { name: '解释代码' })).toBeEnabled());
expect(api.sendConversation.mock.calls[1][1]).toEqual(original);
fireEvent.click(screen.getByRole('button', { name: '解释代码' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(3));
expect(api.sendConversation.mock.calls[2][1].text).toBe(shortcuts[1].prompt);
expect(api.sendConversation.mock.calls[2][1].requestId).not.toBe(original.requestId);
});
it('updates published shortcuts without replacing chat history or a composed draft, and supports clearing the list', async () => {
const old = { teacher_id: 'shortcut-refresh', version: 1, is_default: true, definition: { ...definition, config_id: 'shortcut-refresh', shortcuts: [{ label: '旧入口', prompt: '旧提示词' }] } };
const conversation = { ...topic('shortcut-chat'), definition: old.definition,
conversation: { agentId: old.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'past' },
requests: [request({ id: 'past', text: '之前的问题', response: '之前的回答' })] };
api.conversation.mockResolvedValue({ topic: conversation, before: null });
const state = (agent: typeof old) => ({ catalog: { revision: agent.version, items: [agent] }, config: { enabled: true, published_version: agent.version, revision: agent.version, definition: agent.definition } });
const view = render(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={old} catalogState={state(old)} />);
await screen.findByText('之前的回答');
await waitFor(() => expect(screen.getByRole('button', { name: '旧入口' })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '尚未发出的草稿' } });
const updated = { ...old, version: 2, definition: { ...old.definition, shortcuts: [{ label: '新入口', prompt: '新的实际提示词' }] } };
view.rerender(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={updated} catalogState={state(updated)} />);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('尚未发出的草稿');
expect(screen.queryByRole('button', { name: '新入口' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
expect(screen.getByRole('button', { name: '新入口' })).toBeVisible();
expect(screen.queryByRole('button', { name: '旧入口' })).not.toBeInTheDocument();
const cleared = { ...updated, version: 3, definition: { ...updated.definition, shortcuts: [] } };
view.rerender(<TeacherChatPanel projectId="p" sourceId="c" selectedAgent={cleared} catalogState={state(cleared)} />);
expect(screen.queryByTestId('consultation-shortcuts')).not.toBeInTheDocument();
expect(screen.getByText('之前的回答')).toBeVisible();
expect(api.sendConversation).not.toHaveBeenCalled();
});
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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' });
await waitFor(() => expect(help()).toBeEnabled());
expect(screen.getByTestId('consultation-shortcuts')).toContainElement(help());
expect(screen.getByTestId('consultation-composer-actions')).not.toContainElement(help());
expect(help()).toHaveAccessibleName('帮我整体看🧠');
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '我先自己想想' } });
expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' })).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { 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((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeDisabled();
fireEvent.change(input, { target: { value: '继续讨论' } });
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { 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();
});
});
it('selects a teacher model for the next send and retains it after remount', async () => {
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const trigger = screen.getByRole('button', { name: /老师模型/ });
fireEvent.keyDown(trigger, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'other-model' }));
fireEvent.change(screen.getByRole('textbox', { name: '向智能体提问' }), { target: { value: '我的想法' } });
api.send.mockResolvedValue(first);
fireEvent.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ modelId: 'other-model', text: '我的想法' })));
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByRole('button', { name: /老师模型:other-model/ });
expect(screen.getByRole('button', { name: '语音输入' })).toBeInTheDocument();
});