Files
makelore/tests/unit/teacher-conversation-panel.test.tsx
brother7 1c07f7b5cb
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
Merge remote main with teacher streaming and composer updates
2026-09-29 21:37:50 +08:00

238 lines
17 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic, TeacherRequest } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(),
list: vi.fn(), read: vi.fn(), legacyHistory: vi.fn(), legacyRead: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), updateDiscussion: vi.fn(), cancel: vi.fn(), seenConversation: vi.fn() }));
const identity = vi.hoisted(() => ({ id: 0 }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base,
teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations',
teacherTopicsPath: (project: string) => '/legacy/' + project }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid-' + identity.id } }) }));
const definition: TeacherDefinition = { schema_version: 1, config_id: 'agent-a', teacher_id: 'teacher',
name: '设计老师', description: '', avatar_id: 'avatar-01', welcome_message: '聊聊吧', suggested_questions: [],
system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1000 } };
const agent = { teacher_id: 'agent-a', version: 1, definition, is_default: true };
function request(id: string, text = '旧的问题'): TeacherRequest {
return { id, text, references: [], createdAt: id, sourceCursor: { seq: 0, workerGeneration: 0 },
sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '原来的回答',
projectId: 'project-a', projectName: '天气', sourceConversationId: 'source-a', teacherVersion: 1 };
}
function topic(requests = [request('1')]): TeacherTopic {
return { schemaVersion: 1, revision: 1, id: 'chat', accountId: 'kid', projectId: 'project-a', sourceConversationId: 'project',
definition, version: 1, createdAt: 'now', updatedAt: 'now', requests,
conversation: { agentId: 'agent-a', segmentTurns: 1, discussions: {} } };
}
let stream: EventTarget;
beforeEach(() => {
vi.resetAllMocks(); identity.id++; localStorage.clear(); stream = new EventTarget();
api.config.mockResolvedValue({ enabled: true, definition });
api.catalog.mockResolvedValue({ items: [agent] });
api.conversation.mockImplementation(async projectId => ({ topic: projectId === 'project-a' ? topic() : null, before: null }));
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.events.mockResolvedValue(Object.assign(stream, { close: vi.fn() }));
api.seenConversation.mockImplementation(async (projectId, _agent, requestId) => ({ ...topic(), projectId, conversation: { ...topic().conversation!, seenThrough: requestId } }));
api.sendConversation.mockImplementation(async (projectId, _agent, input) => ({ ...topic(), projectId, revision: 2,
requests: [...topic().requests, { ...request(input.requestId, input.text), projectId: input.projectId, sourceConversationId: input.sourceConversationId }] }));
});
afterEach(cleanup);
const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source-a', selectedAgent: agent };
async function mount() { let view!: ReturnType<typeof render>; await act(async () => { view = render(<TeacherChatPanel {...props} />); }); return view; }
describe('continuous agent panel', () => {
it.each([undefined, 'suggestions', 'check-in'] as const)('shows question/check-in and reply times for %s across teacher reloads', async intent => {
const current = { ...request('timestamped'), createdAt: '2026-09-28T08:01:02Z', finishedAt: '2026-09-28T08:01:42Z', intent,
suggestedQuestions: ['接着聊'], response: '这一轮的回答' };
const saved = topic([current, request('legacy')]);
api.conversation.mockResolvedValue({ topic: saved, before: null });
api.seenConversation.mockImplementation(async () => saved);
const view = await mount();
expect(view.container.querySelectorAll('time')).toHaveLength(2);
expect(view.container.querySelectorAll('time')[0]).toHaveAttribute('datetime', '2026-09-28T08:01:02.000Z');
expect(view.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z');
expect(screen.getAllByText('时间未记录')).toHaveLength(2);
expect(screen.getByRole('button', { name: '接着聊' })).toBeVisible();
view.unmount();
const reloaded = await mount();
expect(reloaded.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z');
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('waits for the terminal reply timestamp while preserving the question time', async () => {
const current = { ...request('streaming'), status: 'running' as const, createdAt: '2026-09-28T08:01:02Z' };
const saved = topic([current]);
api.conversation.mockResolvedValue({ topic: saved, before: null });
api.seenConversation.mockImplementation(async () => saved);
const view = await mount();
expect(view.container.querySelectorAll('time')).toHaveLength(1);
expect(view.container.querySelector('[data-message-time="pending"]')).toBeVisible();
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...saved, revision: 2, requests: [{ ...current, status: 'completed', finishedAt: '2026-09-28T08:01:42Z' }] }) })));
expect(view.container.querySelectorAll('time')).toHaveLength(2);
expect(view.container.querySelector('[data-message-time="pending"]')).toBeNull();
});
it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => {
const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true,
references: [{ kind: 'code' as const, text: '原项目代码' }] };
api.conversation.mockImplementation(async projectId => ({ topic: projectId === 'project-a' ? topic([original]) : null, before: null }));
api.seenConversation.mockImplementation(async () => topic([original]));
let finish!: (value: TeacherTopic) => void;
api.sendConversation.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
const view = await mount();
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
expect(screen.queryByTestId('teacher-reply')).toBeNull();
expect(api.sendConversation).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole('textbox'), { target: { value: '仍在编辑的草稿' } });
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" sourceId="source-b" />));
expect(screen.queryByRole('button', { name: '重新回答' })).toBeNull();
expect(screen.getByRole('textbox')).toHaveValue('');
await act(async () => view.rerender(<TeacherChatPanel {...props} sourceId="another-source" />));
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
view.rerender(<TeacherChatPanel {...props} />);
const retry = screen.getByRole('button', { name: '重新回答' });
fireEvent.click(retry); fireEvent.click(retry);
expect(api.sendConversation).toHaveBeenCalledTimes(1);
const sent = api.sendConversation.mock.calls[0][2];
expect(sent).toEqual({ requestId: expect.any(String), projectId: 'project-a', sourceConversationId: 'source-a',
text: original.text, references: original.references });
expect(sent.requestId).not.toBe(original.id);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '等待时补充的草稿' } });
await act(async () => finish({ ...topic([original, { ...request(sent.requestId), response: '完整回答' }]), revision: 2 }));
expect(screen.getByText('完整回答')).toBeVisible();
expect(screen.getByRole('textbox')).toHaveValue('等待时补充的草稿');
expect(JSON.parse(localStorage.getItem(`makelore-consultation-draft:kid-${identity.id}:project:project-a:agent:agent-a`)!)).toMatchObject({ text: '等待时补充的草稿' });
});
it('ignores a previous project fetch and external snapshot after a project switch', async () => {
let resolve!: (value: { topic: TeacherTopic; before: null }) => void;
const delayed = new Promise<{ topic: TeacherTopic; before: null }>(done => { resolve = done; });
api.conversation.mockImplementation(project => project === 'project-a' ? delayed : Promise.resolve({ topic: null, before: null }));
const view = await mount();
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" externalTopic={topic()} />));
await act(async () => resolve({ topic: topic(), before: null }));
expect(screen.queryByText('旧的问题')).toBeNull();
expect(api.seenConversation).not.toHaveBeenCalled();
expect(screen.getByRole('textbox')).toHaveValue('');
});
it('adopts an old global draft only in its recorded project and preserves the original', async () => {
const key = `makelore-consultation-draft:kid-${identity.id}:agent:agent-a`;
const original = JSON.stringify({ text: '旧全局草稿', projectId: 'project-a', sourceId: 'source-a' });
localStorage.setItem(key, original);
const olderProjectKey = `makelore-consultation-draft:kid-${identity.id}:project-a:agent:agent-a`;
localStorage.setItem(olderProjectKey, JSON.stringify({ text: '升级前更旧的草稿' }));
const view = await mount();
expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿');
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" />));
expect(screen.getByRole('textbox')).toHaveValue('');
expect(localStorage.getItem(key)).toBe(original);
expect(localStorage.getItem(olderProjectKey)).toContain('升级前更旧的草稿');
await act(async () => view.rerender(<TeacherChatPanel {...props} />));
expect(screen.getByRole('textbox')).toHaveValue('旧全局草稿');
});
it('keeps one chat per project and restores its own history and draft when switching back', async () => {
const view = await mount();
expect(screen.queryByRole('button', { name: '智能体新话题' })).toBeNull();
expect(screen.queryByRole('combobox')).toBeNull();
expect(api.create).not.toHaveBeenCalled();
expect(screen.getByText('旧的问题')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox'), { target: { value: '天气项目草稿' } });
await act(async () => view.rerender(<TeacherChatPanel {...props} projectId="project-b" projectName="游戏" sourceId="source-b" />));
expect(screen.getByRole('textbox')).toHaveValue('');
expect(screen.queryByText('旧的问题')).toBeNull();
fireEvent.change(screen.getByRole('textbox'), { target: { value: '游戏问题' } });
fireEvent.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledWith('project-b', 'agent-a', expect.objectContaining({
projectId: 'project-b', sourceConversationId: 'source-b', text: '游戏问题' })));
await act(async () => view.rerender(<TeacherChatPanel {...props} />));
expect(screen.getByRole('textbox')).toHaveValue('天气项目草稿');
expect(screen.getByText('旧的问题')).toBeInTheDocument();
expect(screen.queryByText('游戏问题')).toBeNull();
});
it('merges older pages and streamed current turns without replacing earlier messages', async () => {
api.conversation.mockImplementation(async (_project, _id, before) => ({ topic: before ? topic([request('0', '更早的问题')]) : topic(), before: before ? null : '1' }));
await mount();
fireEvent.click(screen.getByText('查看更早消息'));
await screen.findByText('更早的问题');
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
...topic([request('2', '新的问题')]), revision: 2,
}) })));
expect(screen.getByText('更早的问题')).toBeInTheDocument();
expect(screen.getByText('旧的问题')).toBeInTheDocument();
expect(screen.getByText('新的问题')).toBeInTheDocument();
});
it('rejects stale references and prevents carrying an old Pi reply into another Pi draft', async () => {
const bring = vi.fn();
let view!: ReturnType<typeof render>;
await act(async () => { view = render(<TeacherChatPanel {...props} quote={{ kind: 'code', text: '旧项目代码' }} onBringBack={bring} />); });
fireEvent.change(screen.getByRole('textbox'), { target: { value: '看看' } });
view.rerender(<TeacherChatPanel {...props} sourceId="source-b" onBringBack={bring} />);
fireEvent.click(screen.getByRole('button', { name: '提问' }));
expect(api.sendConversation).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('引用来自另一个项目');
expect(screen.getByText('请打开来源项目和操作对话后带回')).toBeDisabled();
});
it('opens an empty chat without a model call and creates it only through the first send', async () => {
api.conversation.mockResolvedValue({ topic: null, before: null });
await mount();
expect(api.sendConversation).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole('textbox'), { target: { value: '你好' } });
fireEvent.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.sendConversation).toHaveBeenCalledTimes(1));
expect(api.create).not.toHaveBeenCalled();
});
it('merges a delayed older page without overwriting a newer streamed answer', async () => {
let resolve!: (value: {topic: TeacherTopic; before: null}) => void;
const page = new Promise<{topic: TeacherTopic; before: null}>(done => { resolve = done; });
api.conversation.mockImplementation((_project, _id, before) => before ? page : Promise.resolve({ topic: topic(), before: '1' }));
await mount();
fireEvent.click(screen.getByText('查看更早消息'));
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', {data: JSON.stringify({
...topic([{...request('1'), response: '更新后的回答'}]), revision: 3,
})})));
await act(async () => resolve({topic: topic([request('0', '分页里较早的问题'), request('1')]), before: null}));
expect(screen.getByText('分页里较早的问题')).toBeVisible();
expect(screen.getByText('更新后的回答')).toBeVisible();
});
it('keeps legacy records read only when a live update arrives and returns to the same chat', async () => {
const old = {...topic([request('old', '旧来源会话')]), id: 'legacy', conversation: undefined};
api.legacyHistory.mockResolvedValue({items: [{id: 'legacy', title: '来源记录', legacySourceId: 'removed-source'}], lastSelectedTopicId: null});
api.legacyRead.mockResolvedValue(old);
const view = await mount();
fireEvent.click(screen.getByText('旧记录'));
fireEvent.click(await screen.findByText('来源记录(只读)'));
await screen.findByText('旧来源会话');
view.rerender(<TeacherChatPanel {...props} externalTopic={{...topic(), revision: 4}} />);
expect(screen.getByText('旧来源会话')).toBeVisible();
expect(screen.getByRole('button', {name: '提问'})).toBeDisabled();
fireEvent.click(screen.getByText('返回与智能体的聊天'));
await screen.findByText('旧的问题');
expect(api.sendConversation).not.toHaveBeenCalled();
});
});
it.each([undefined, 'suggestions'] as const)('updates reasoning and answer independently before completion (%s)', async intent => {
let current = topic([{ ...request('streaming'), intent, presentation: 'reply-v1', status: 'running', response: '' }]);
api.conversation.mockResolvedValue({ topic: current, before: null });
api.seenConversation.mockImplementation(async () => current);
await mount();
await waitFor(() => expect(api.events).toHaveBeenCalled());
const emit = async (patch: Partial<TeacherRequest>) => {
current = { ...current, revision: current.revision + 1, requests: [{ ...current.requests[0], ...patch }] };
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })));
};
await emit({ reasoning: '先读项目。' });
expect(screen.getByTestId('teacher-reasoning')).toHaveAttribute('open');
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先读项目。');
await emit({ reasoning: '先读项目。再分析玩法。', response: '正在生成正文' });
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('正在生成正文');
expect(screen.getByTestId('teacher-reply')).not.toHaveTextContent('再分析玩法');
expect(screen.getByRole('button', { name: '停止回复' })).toBeEnabled();
await emit({ status: 'completed', response: '完整正文', suggestedQuestions: ['再讲一个例子'] });
expect(screen.getByTestId('teacher-reasoning')).not.toHaveAttribute('open');
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('完整正文');
expect(screen.getByRole('button', { name: '再讲一个例子' })).toBeEnabled();
fireEvent.click(screen.getByText('思考过程'));
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('再分析玩法。');
});