620 lines
39 KiB
TypeScript
620 lines
39 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(),
|
|
updateDiscussion: vi.fn(),
|
|
cancel: vi.fn(),
|
|
save: vi.fn(),
|
|
events: 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.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('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('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.getByText('服务端简介')).toBeVisible();
|
|
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 closed', async () => {
|
|
const closed = vi.fn();
|
|
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
|
|
await ready();
|
|
const old = streams.get('first')!;
|
|
fireEvent.change(screen.getByRole('combobox', { name: '智能体话题' }), {
|
|
target: { value: 'second' },
|
|
});
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('combobox', { name: '智能体话题' })).toHaveValue('second')
|
|
);
|
|
act(() =>
|
|
old.dispatchEvent(
|
|
new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 999 }) })
|
|
)
|
|
);
|
|
expect(screen.getByRole('combobox', { name: '智能体话题' })).toHaveValue('second');
|
|
fireEvent.click(screen.getByRole('button', { name: '关闭智能体' }));
|
|
expect(closed).toHaveBeenCalledOnce();
|
|
view.unmount();
|
|
expect(api.cancel).not.toHaveBeenCalled();
|
|
expect(old.close).toHaveBeenCalled();
|
|
});
|
|
it('shows disabled history while blocking a new question', async () => {
|
|
api.config.mockResolvedValue({ enabled: false, definition });
|
|
render(<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" />);
|
|
await screen.findByRole('heading', { name, exact: true });
|
|
expect(screen.getByText('这是运营发布的简介')).toBeVisible();
|
|
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('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => {
|
|
const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题';
|
|
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
|
|
...first, revision: 2, requests: [suggestionsRequest({ id: input.requestId, status: 'running', response: streamingJson, suggestedQuestions: undefined })],
|
|
}));
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
const help = screen.getByRole('button', { name: '帮我看看', exact: true });
|
|
await waitFor(() => expect(help).toBeEnabled());
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我自己还想问的另一件事' } });
|
|
fireEvent.click(help);
|
|
|
|
await screen.findByText('我看看你最近做到了哪里…');
|
|
expect(screen.queryByText(streamingJson)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
|
|
expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({
|
|
intent: 'suggestions', text: '帮我看看', sourceConversationId: 'c',
|
|
}));
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我自己还想问的另一件事');
|
|
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeDisabled();
|
|
await waitFor(() => expect(streams.has('first')).toBe(true));
|
|
const sent = api.send.mock.calls[0][2] as TeacherSend;
|
|
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
|
|
...first, revision: 3, requests: [suggestionsRequest({ id: sent.requestId })],
|
|
}) })));
|
|
|
|
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
|
|
expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument();
|
|
for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled();
|
|
expect(screen.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toBeEnabled();
|
|
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled();
|
|
expect(api.send).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我自己还想问的另一件事');
|
|
});
|
|
|
|
it.each([
|
|
[suggestedQuestions[0], undefined],
|
|
['我也说不清,你带我看看', 'guided-help'],
|
|
] as const)('sends the selected help action "%s" without replacing the student draft', async (text, intent) => {
|
|
const previous = suggestionsRequest();
|
|
api.read.mockResolvedValue({ ...first, requests: [previous] });
|
|
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
|
|
...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, intent: input.intent, response: '我们先从你刚才做的那一步看起。' })],
|
|
}));
|
|
const view = render(<TeacherChatPanel projectId="p" sourceId="c" quote={{ kind: 'code', text: '学生正在引用的一段代码' }} />);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled());
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还在整理的自由提问' } });
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: text, exact: true }));
|
|
await screen.findByText('我们先从你刚才做的那一步看起。');
|
|
expect(api.send).toHaveBeenCalledTimes(1);
|
|
const sent = api.send.mock.calls[0][2] as TeacherSend;
|
|
expect(sent).toMatchObject({ text, sourceConversationId: 'c' });
|
|
expect(sent.references).toEqual([]);
|
|
expect(sent.intent).toBe(intent);
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在整理的自由提问');
|
|
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
|
|
view.unmount();
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在整理的自由提问');
|
|
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
|
|
expect(api.send).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries an uncertain suggestions request after reopening without losing the student draft', async () => {
|
|
api.send.mockRejectedValue(new Error('网络暂不可用'));
|
|
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled());
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
|
|
await screen.findByText('网络暂不可用');
|
|
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
|
|
expect(originalRequest.intent).toBe('suggestions');
|
|
view.unmount();
|
|
|
|
render(<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.change(screen.getByLabelText('向智能体提问'), { target: { value: '我又补了一点自己的想法' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
|
|
await screen.findByText('网络暂不可用');
|
|
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我又补了一点自己的想法');
|
|
});
|
|
|
|
it('shows a failed suggestion request without leaking raw JSON and starts a fresh request on another try', async () => {
|
|
const failed = suggestionsRequest({ status: 'failed', response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' });
|
|
api.read.mockResolvedValue({ ...first, requests: [failed] });
|
|
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
|
|
...first, revision: 2, requests: [failed, suggestionsRequest({ id: input.requestId })],
|
|
}));
|
|
render(<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.getByRole('button', { name: '再请智能体看看', exact: true }));
|
|
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
|
|
expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' });
|
|
expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id);
|
|
});
|
|
|
|
it.each(['reopen', 'stream'] as const)('reconciles an accepted request learned through %s before retrying a failed reply', async (via) => {
|
|
let accepted: TeacherRequest;
|
|
api.send.mockRejectedValueOnce(new Error('响应中途断开')).mockImplementation(async (_base, _id, input: TeacherSend) => ({
|
|
...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })],
|
|
}));
|
|
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeEnabled());
|
|
await waitFor(() => expect(streams.has('first')).toBe(true));
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '帮我看看', exact: true }));
|
|
await screen.findByText('响应中途断开');
|
|
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
|
|
accepted = suggestionsRequest({ id: originalRequest.requestId, status: 'failed', response: '', suggestedQuestions: undefined, error: '智能体已接到,但这次回复失败了' });
|
|
const recovered = { ...first, revision: 2, requests: [accepted] };
|
|
if (via === 'reopen') {
|
|
view.unmount();
|
|
api.read.mockResolvedValue(recovered);
|
|
render(<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.getByRole('button', { name: '再请智能体看看', exact: true })).toBeEnabled());
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着');
|
|
expect(api.send).toHaveBeenCalledTimes(1);
|
|
fireEvent.click(screen.getByRole('button', { name: '再请智能体看看', exact: true }));
|
|
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
|
|
expect(api.send).toHaveBeenCalledTimes(2);
|
|
expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId);
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着');
|
|
});
|
|
|
|
it('still submits a student-written question with its quote and clears only that submitted draft', async () => {
|
|
const quote = { kind: 'code' as const, text: 'setScore(score + 1)' };
|
|
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
|
|
...first, revision: 2, requests: [request({
|
|
id: input.requestId, text: input.text, references: input.references ?? [], response: '你觉得每点一次,分数应该怎么变化?',
|
|
})],
|
|
}));
|
|
const view = render(<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.getByRole('button', { name: '帮我看看', exact: true })).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.queryByRole('button', { name: '帮我看看', exact: true })).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: '智能体新话题' }));
|
|
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码', undefined));
|
|
});
|
|
});
|
|
|
|
|
|
describe('pinned discussion workspace', () => {
|
|
const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: {
|
|
kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }],
|
|
} };
|
|
async function openDiscussion(status: 'active' | 'offered' = 'active') {
|
|
const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } };
|
|
api.read.mockResolvedValue(current);
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await screen.findByText('一起想我的宠物游戏');
|
|
await waitFor(() => expect(streams.has('first')).toBe(true));
|
|
return current;
|
|
}
|
|
it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => {
|
|
const current = await openDiscussion();
|
|
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] }));
|
|
fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true }));
|
|
expect(api.send).not.toHaveBeenCalled();
|
|
expect(screen.getByText('接着聊:养只小狗')).toBeVisible();
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我希望是柯基' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
|
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' })));
|
|
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
});
|
|
it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => {
|
|
const current = await openDiscussion();
|
|
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } });
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还在想的下一句' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true }));
|
|
await screen.findByText('先留在这里');
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在想的下一句');
|
|
expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true');
|
|
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
|
await screen.findByText('我们慢慢聊。');
|
|
expect(api.send.mock.calls[0][2].discussion).toBeUndefined();
|
|
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } });
|
|
fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true }));
|
|
await screen.findByRole('button', { name: '养只小狗', exact: true });
|
|
expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' });
|
|
});
|
|
it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => {
|
|
const current = await openDiscussion('offered');
|
|
api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } });
|
|
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled();
|
|
fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true }));
|
|
await screen.findByRole('button', { name: '把想法理一理', exact: true });
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '不要丢掉的草稿' } });
|
|
api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] });
|
|
fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true }));
|
|
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
|
expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' });
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('不要丢掉的草稿');
|
|
});
|
|
it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => {
|
|
const current = await openDiscussion();
|
|
const stream = streams.get('first')!;
|
|
const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] };
|
|
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
|
|
expect(screen.getByRole('status')).toHaveTextContent('正在梳理');
|
|
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible();
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '下一轮草稿' } });
|
|
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
|
|
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
|
|
await screen.findByText('这次先停在这里。想继续时可以再说说。');
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('下一轮草稿');
|
|
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) })));
|
|
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
expect(screen.getByText('一起想小狗和花园')).toBeVisible();
|
|
});
|
|
it('sends a normal suggested question directly without changing the composer draft', async () => {
|
|
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
|
|
api.read.mockResolvedValue(current);
|
|
api.send.mockResolvedValue({ ...current, revision: 2 });
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await screen.findByRole('button', { name: '怎样知道小狗喜欢我?' });
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '自己的草稿' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '怎样知道小狗喜欢我?' }));
|
|
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
|
expect(api.send.mock.calls[0][2].text).toBe('怎样知道小狗喜欢我?');
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('自己的草稿');
|
|
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
|
|
it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => {
|
|
const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: {
|
|
kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }],
|
|
} } };
|
|
api.read.mockResolvedValue(current);
|
|
api.send.mockResolvedValue({ ...current, revision: 2 });
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await screen.findByText('回到家时');
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '自己还没说完的话' } });
|
|
fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]);
|
|
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
|
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } });
|
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('自己还没说完的话');
|
|
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
});
|
|
|
|
|
|
describe('merged cloud teacher classroom', () => {
|
|
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.create.mockResolvedValue(selected);
|
|
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({ ...selected, revision: 2, requests: [request({ id: input.requestId, text: input.text, response: '先看看玩家会做什么。' })] }));
|
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
await waitFor(() => expect(screen.getByLabelText('新话题使用的智能体')).toBeEnabled());
|
|
expect(api.create).not.toHaveBeenCalled();
|
|
fireEvent.change(screen.getByLabelText('新话题使用的智能体'), { target: { value: '9' } });
|
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我想做一个小游戏' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
|
await screen.findByText('先看看玩家会做什么。');
|
|
expect(api.create).toHaveBeenCalledWith('p/agent-topics', undefined, undefined, 9);
|
|
expect(api.send.mock.calls[0][2]).toMatchObject({ sourceConversationId: 'c', presentation: 'discussion-v1' });
|
|
expect(screen.getByRole('heading', { 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('向智能体提问');
|
|
fireEvent.change(input, { target: { value: '继续讨论' } });
|
|
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
|
|
expect(screen.getByRole('button', { name: '帮我看看', exact: true })).toBeDisabled();
|
|
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();
|
|
});
|
|
});
|