Files
makelore/tests/unit/teacher-companion.test.tsx
鲨鱼辣椒 cafebca057
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
fix(chat): reconcile reply recovery with continuous agent conversations
2026-09-28 18:14:12 +08:00

690 lines
49 KiB
TypeScript

import { useState } from 'react';
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
import { TeacherCompanion } from '@/pages/Chat/TeacherCompanion';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { TEACHER_CHECK_IN_INTERVAL_MS, type TeacherCatalog, type TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({ catalog: vi.fn(), config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn(), conversation: vi.fn(), sendConversation: vi.fn(), seenConversation: vi.fn() }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: 'kid' } }) }));
const definition: TeacherDefinition = {
schema_version: 1, teacher_id: 'coding-teacher', name: '运营配置的智能体', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '指导', skills: [],
model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
function checkInTopic(projectId = 'project', id = 'check-1'): TeacherTopic {
return { schemaVersion: 1, revision: 2, id: 'topic-' + projectId, role: 'teacher', accountId: 'kid', projectId, sourceConversationId: 'project', version: 1, definition, createdAt: 'now', updatedAt: 'now', requests: [{ id, intent: 'check-in', text: '', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '你刚才想加排行榜,我们一起想想比什么更有意思?' }] };
}
function Harness({ projectId = 'project', sourceId = 'source', busy = false, archived = false }: { projectId?: string; sourceId?: string; busy?: boolean; archived?: boolean }) {
const [open, setOpen] = useState(false);
const [teacherComposing, setTeacherComposing] = useState(false);
const companion = useTeacherCompanion({ projectId, sourceId, sourceBusy: busy, sourceArchived: archived, teacherOpen: open, teacherComposing });
return <><TeacherCompanion companion={companion} open={open} onOpen={(id) => { if (id) companion.selectAgent(id); setOpen(true); }}
onToggle={(id) => {
if (open && (!id || id === companion.selectedAgent?.teacher_id)) setOpen(false);
else { if (id) companion.selectAgent(id); setOpen(true); }
}} />
{open && <TeacherChatPanel key={companion.selectedAgent?.teacher_id ?? projectId + ':history'} selectedAgent={companion.selectedAgent} catalogState={companion.catalogState} reloadCatalog={companion.reloadCatalog} projectId={projectId} sourceId="source" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} />}</>;
}
async function mount(props: Parameters<typeof Harness>[0] = {}) {
let view!: ReturnType<typeof render>;
await act(async () => { view = render(<Harness {...props} />); });
return view;
}
async function advance(ms = TEACHER_CHECK_IN_INTERVAL_MS) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
}
beforeEach(() => {
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
});
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
api.config.mockResolvedValue({ enabled: true, definition });
api.catalog.mockResolvedValue({ items: [] });
api.conversation.mockResolvedValue({ topic: null, before: null });
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.events.mockImplementation(async () => Object.assign(new EventTarget(), { close: vi.fn() }));
api.checkIn.mockImplementation(async (projectId: string) => ({ topic: checkInTopic(projectId) }));
});
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('manual agent refresh', () => {
it('refreshes while busy and composing, shares one request, and sends through the same agent after publication updates', async () => {
const original = { teacher_id: 'refresh-first-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-first-method', name: '方法老师', welcome_message: '一起想想办法' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴', welcome_message: '我们一起试试新办法' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount({ busy: true });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我正在写的问题' } });
const listCalls = api.list.mock.calls.length;
const catalogCalls = api.catalog.mock.calls.length;
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeDisabled();
expect(screen.getByText('正在刷新…', { selector: '[role="status"]' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新智能体' }));
expect(api.catalog).toHaveBeenCalledTimes(catalogCalls + 1);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
await act(async () => resolve({ items: [updated, added] }));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(updated.definition.welcome_message)).toBeVisible();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我正在写的问题');
expect(api.list).toHaveBeenCalledTimes(listCalls);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
const created = { ...checkInTopic(), id: 'refresh-first-chat', definition: updated.definition, version: updated.version, requests: [],
conversation: { agentId: original.teacher_id, segmentTurns: 0, discussions: {} } };
api.sendConversation.mockResolvedValue(created);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ projectId: 'project', sourceConversationId: 'source', text: '我正在写的问题' }));
});
it('preserves the ongoing chat, old turn version and draft across refresh and later removal', async () => {
const original = { teacher_id: 'refresh-method', version: 3, is_default: true, definition: { ...definition, config_id: 'refresh-method', name: '方法老师' } };
const updated = { ...original, version: 4, definition: { ...original.definition, name: '方法伙伴' } };
const added = { teacher_id: 'refresh-experience', version: 9, is_default: false, definition: { ...definition, config_id: 'refresh-experience', name: '体验朋友' } };
const history: TeacherTopic = { ...checkInTopic(), version: 1, definition: original.definition,
requests: [{ ...checkInTopic().requests[0], teacherVersion: 1 }],
conversation: { agentId: original.teacher_id, segmentTurns: 1, discussions: {}, seenThrough: 'check-1' } };
api.catalog.mockResolvedValue({ items: [original] });
api.conversation.mockImplementation(async (id) => ({ topic: id === original.teacher_id ? history : null, before: null }));
const view = await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '接着刚才说' } });
api.catalog.mockResolvedValue({ items: [updated, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('complementary', { name: '方法伙伴咨询' })).toBeVisible();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('接着刚才说');
expect(history.requests[0].teacherVersion).toBe(1);
expect(api.read).not.toHaveBeenCalled();
expect(api.list).not.toHaveBeenCalled();
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '智能体新话题' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('接着刚才说');
api.sendConversation.mockResolvedValue(history);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.sendConversation).toHaveBeenCalledWith(original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'other' }));
fireEvent.change(input, { target: { value: '停用时保留的问题' } });
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '与方法伙伴聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(input).toHaveValue('停用时保留的问题');
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(history.requests[0].response)).toBeVisible();
expect(api.create).not.toHaveBeenCalled();
});
it.each(['catalog', 'config'] as const)('keeps the current catalog when %s fails and lets the user retry', async (failedEndpoint) => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const added = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [original] });
await mount();
api.catalog.mockResolvedValue({ items: [original, added] });
api[failedEndpoint].mockRejectedValueOnce(new Error('offline'));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('刷新失败,请重试')).toBeVisible();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('方法老师');
expect(screen.queryByRole('button', { name: '与体验朋友聊天' })).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(screen.queryByText('刷新失败,请重试')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('can fetch the first delivered agent while the previous catalog was unavailable', async () => {
api.config.mockResolvedValue({ enabled: false, definition: null });
await mount();
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent('体验朋友');
expect(screen.getByText('已更新', { selector: '[role="status"]' })).toBeVisible();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('keeps an open project draft in place when the first delivered agent appears', async () => {
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
const input = screen.getByLabelText('向智能体提问');
fireEvent.change(input, { target: { value: '还没有选择智能体的问题' } });
const added = { teacher_id: 'experience', version: 9, is_default: true, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
api.catalog.mockResolvedValue({ items: [added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(screen.queryByText('查看之前的项目草稿')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(added.definition.name);
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'false');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByLabelText('向智能体提问')).toBe(input);
expect(input).toHaveValue('还没有选择智能体的问题');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.getByText('查看之前的项目草稿')).toBeVisible();
expect(localStorage.getItem('makelore-consultation-draft:kid:project:teacher')).toContain('还没有选择智能体的问题');
});
it('ignores a slow refresh from the previous project without changing the new project status', async () => {
const original = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '旧项目智能体' } };
const current = { ...original, version: 9, definition: { ...original.definition, name: '最新智能体名称' } };
api.catalog.mockResolvedValue({ items: [original] });
const view = await mount();
let resolve!: (catalog: TeacherCatalog) => void;
api.catalog.mockImplementationOnce(() => new Promise<TeacherCatalog>((done) => { resolve = done; }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
api.catalog.mockResolvedValue({ items: [current] });
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
await act(async () => resolve({ items: [original] }));
expect(screen.getByRole('button', { name: '智能体', exact: true })).toHaveTextContent(current.definition.name);
expect(screen.queryByText(original.definition.name)).not.toBeInTheDocument();
expect(screen.queryByText('已更新', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.queryByText('正在刷新…', { selector: '[role="status"]' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新智能体' })).toBeEnabled();
expect(api.checkIn).not.toHaveBeenCalled();
});
});
describe('teacher presence and periodic guidance', () => {
it('restores the last selected disabled agent as read-only history after a restart', async () => {
const saved = { ...checkInTopic(), definition: {...definition, config_id: 'disabled', name: '已停用的老师'},
conversation: {agentId: 'disabled', segmentTurns: 1, discussions: {}, seenThrough: 'check-1'} };
localStorage.setItem('makelore-teacher-presence:kid:selected-agent', JSON.stringify('disabled'));
api.conversation.mockResolvedValue({topic: saved, before: null});
api.seenConversation.mockResolvedValue(saved);
await mount();
expect(api.conversation).toHaveBeenCalledWith('disabled');
await act(async () => fireEvent.click(screen.getByRole('button', {name: '智能体', exact: true})));
expect(screen.getByRole('complementary', {name: '已停用的老师咨询'})).toBeVisible();
expect(screen.getByRole('button', {name: '提问', exact: true})).toBeDisabled();
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('tracks unread replies per agent without creating invitation bubbles or losing the selected chat on a project switch', async () => {
const agents = ['a', 'b'].map(id => ({teacher_id: id, version: 1, is_default: id === 'a', definition: {...definition, config_id: id, name: id}}));
const chats = agents.map(agent => ({...checkInTopic(), id: agent.teacher_id + '-chat', definition: agent.definition,
conversation: {agentId: agent.teacher_id, segmentTurns: 1, discussions: {}}}));
api.catalog.mockResolvedValue({items: agents});
api.conversation.mockImplementation(async id => ({topic: chats.find(chat => chat.definition.config_id === id), before: null}));
api.seenConversation.mockImplementation(async (id, requestId) => {
const chat = chats.find(chat => chat.definition.config_id === id)!;
return {...chat, revision: 3, conversation: {...chat.conversation, seenThrough: requestId}};
});
const view = await mount();
expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(2);
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', {name: '与b聊天'})));
expect(api.seenConversation).toHaveBeenCalledWith('b', 'check-1');
expect(screen.getByRole('button', {name: '与b聊天'}).querySelector('.teacher-companion-unread')).toBeNull();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', {name: '与b聊天'})).toHaveAttribute('aria-pressed', 'true');
expect(api.create).not.toHaveBeenCalled();
});
it('shows the hovered agent introduction in a portal without selecting it or sending a request', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师', description: '把大问题拆成小积木,一起试试看。' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', description: '陪你听听玩家的想法,让作品更有趣。' } };
api.catalog.mockResolvedValue({ items: [a, b] });
const view = await mount();
const method = screen.getByRole('button', { name: '与方法老师聊天' });
const experience = screen.getByRole('button', { name: '与体验朋友聊天' });
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
fireEvent.pointerEnter(method, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(a.definition.description);
expect(view.container.contains(screen.getByTestId('teacher-introduction'))).toBe(false);
fireEvent.pointerLeave(method, { pointerType: 'mouse' });
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
expect(screen.getByTestId('teacher-introduction')).not.toHaveTextContent(a.definition.description);
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(experience).toHaveAttribute('aria-pressed', 'false');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(experience));
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
// The first move may arrive only after the click when crossing a tooltip's
// grace area. It must not reopen the introduction beneath the pointer.
fireEvent.pointerMove(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
fireEvent.pointerLeave(experience, { pointerType: 'mouse' });
fireEvent.pointerMove(document.body, { pointerType: 'mouse', clientX: 100, clientY: 100 });
fireEvent.pointerEnter(experience, { pointerType: 'mouse' });
await advance(250);
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(b.definition.description);
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('opens an introduction on keyboard focus and Escape leaves an unread invitation unacknowledged', async () => {
const unread = { ...checkInTopic(), definition: { ...definition, description: '一起发现代码里的小奥秘。' } };
api.list.mockResolvedValue({ items: [{ id: unread.id }], lastSelectedTopicId: unread.id });
api.read.mockResolvedValue(unread);
const view = await mount();
const button = screen.getByRole('button', { name: '智能体', exact: true });
await act(async () => button.focus());
expect(screen.getByTestId('teacher-introduction')).toHaveTextContent(unread.definition.description);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
view.unmount();
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('does not show an empty introduction and preserves a draft when the same entry closes and reopens the panel', async () => {
await mount();
const button = screen.getByRole('button', { name: '智能体', exact: true });
fireEvent.pointerEnter(button, { pointerType: 'mouse' });
await advance(250);
await act(async () => button.focus());
expect(screen.queryByTestId('teacher-introduction')).not.toBeInTheDocument();
await act(async () => fireEvent.click(button));
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没想好怎么问' } });
await act(async () => fireEvent.click(button));
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'false');
await act(async () => fireEvent.click(button));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没想好怎么问');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
});
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
api.catalog.mockResolvedValue({ items: [a, b] });
const view = await mount();
const first = screen.getByRole('button', { name: '与运营配置的智能体聊天' }).querySelector('img')!;
const second = screen.getByRole('button', { name: '与另一个智能体聊天' }).querySelector('img')!;
expect(first).toHaveAttribute('src', a.definition.icon_url);
expect(second).toHaveAttribute('src', b.definition.icon_url);
fireEvent.error(first);
expect(first.src).toContain('avatar-06.png');
expect(second).toHaveAttribute('src', b.definition.icon_url);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与另一个智能体聊天' })));
const images = view.container.querySelectorAll('[data-testid="teacher-chat-panel"] img');
expect(images).toHaveLength(1);
for (const img of images) expect(img).toHaveAttribute('src', b.definition.icon_url);
});
it('shows every distributed agent in the header and preserves separate drafts without dispatching on switch', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };
api.catalog.mockResolvedValue({ items: [a, b] });
api.create.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
api.sendConversation.mockResolvedValue({ ...checkInTopic(), definition: b.definition, version: b.version, requests: [] });
await mount();
expect(screen.getByRole('button', { name: '与方法老师聊天' })).toBeVisible();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toBeVisible();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
expect(screen.queryByLabelText('新话题使用的智能体')).not.toBeInTheDocument();
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '体验的草稿' } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '方法的草稿' } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('体验的草稿');
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).toHaveBeenCalledWith('experience', expect.objectContaining({projectId: 'project', text: '体验的草稿'}));
});
it('restores each selected agent conversation and uses its stable identity when sending', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友' } };
const oldA = { ...checkInTopic(), id: 'a-topic', conversation: { agentId: 'method', segmentTurns: 0, discussions: {} }, version: 1, definition: a.definition, requests: [] };
const oldB = { ...checkInTopic(), id: 'b-topic', conversation: { agentId: 'experience', segmentTurns: 0, discussions: {} }, version: 8, definition: b.definition, requests: [] };
api.catalog.mockResolvedValue({ items: [a, b] });
api.list.mockResolvedValue({ items: [
{ id: oldB.id, teacherId: b.teacher_id, title: '体验历史' },
{ id: oldA.id, teacherId: a.teacher_id, title: '方法历史' },
], lastSelectedTopicId: oldB.id });
api.conversation.mockImplementation(async id => ({topic: id === 'method' ? oldA : oldB, before: null}));
api.sendConversation.mockResolvedValue({ ...oldA, revision: 3 });
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(api.conversation).toHaveBeenCalledWith('method');
expect(screen.getByRole('complementary', { name: '方法老师咨询' })).toBeVisible();
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '继续原来的讨论' } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.sendConversation.mock.calls[0][0]).toBe('method');
expect(api.create).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
expect(api.conversation).toHaveBeenCalledWith('experience');
});
it('ignores a late initial history read after the student selects a different agent', async () => {
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, config_id: 'a', name: '方法老师' } };
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, config_id: 'b', name: '体验朋友' } };
const old = { ...checkInTopic(), definition: a.definition };
api.catalog.mockResolvedValue({ items: [a, b] });
api.list.mockResolvedValue({ items: [{ id: old.id, teacherId: 'a' }], lastSelectedTopicId: old.id });
let resolve!: (view: {topic: TeacherTopic; before: null}) => void;
const pending = new Promise<{topic: TeacherTopic; before: null}>((done) => { resolve = done; });
api.conversation.mockImplementation(id => id === 'a' ? pending : Promise.resolve({topic: null, before: null}));
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' })));
await act(async () => resolve({topic: old, before: null}));
expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible();
expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(api.conversation).toHaveBeenCalledWith('a');
expect(api.send).not.toHaveBeenCalled();
});
it('shows the selected agent greeting instead of borrowing the default agent greeting', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '默认智能体欢迎语' } });
const selected = { ...checkInTopic(), requests: [], definition: { ...definition, name: '体验朋友', avatar_id: 'avatar-09', welcome_message: '我们聊聊你的体验' } };
api.list.mockResolvedValue({ items: [{ id: selected.id }], lastSelectedTopicId: selected.id });
api.read.mockResolvedValue(selected);
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('我们聊聊你的体验');
expect(screen.queryByText('默认智能体欢迎语')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true }).querySelector('img')?.src).toContain('avatar-09.png');
expect(api.checkIn).not.toHaveBeenCalled();
});
it('does not dispatch background turns or subscribe while viewing legacy friend history', async () => {
const legacy = { ...checkInTopic(), role: 'friend' as const, requests: [] };
api.list.mockResolvedValue({ items: [{ id: legacy.id, legacyRole: 'friend' }], lastSelectedTopicId: legacy.id });
api.read.mockResolvedValue(legacy);
await mount();
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.events).not.toHaveBeenCalled();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('shows the published greeting immediately without a model request and remembers dismissal across projects', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '有什么编程问题想一起讨论?' } });
const view = await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('有什么编程问题想一起讨论?');
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '等会儿聊' })));
view.unmount();
const next = await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('gives a real unread reply priority over the greeting and does not show the greeting after acknowledgment', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } });
api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' });
api.read.mockResolvedValue(checkInTopic());
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(screen.queryByText('欢迎一起创作')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '等会儿聊' })));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('consumes the static greeting when consultation opens through the avatar and remembers it across projects', async () => {
const greeting = '有什么编程问题想一起讨论?';
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: greeting } });
const view = await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'welcome');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(within(screen.getByTestId('teacher-chat-panel')).getByText(greeting)).toBeVisible();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.getByTestId('teacher-chat-panel')).toBeVisible();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
view.unmount();
const next = await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it.each([false, true])('consumes the static greeting when consultation opens before config loads (closed before load: %s)', async (closeBeforeLoad) => {
const config = { enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } };
let resolve!: (value: typeof config) => void;
api.config.mockImplementationOnce(() => new Promise<typeof config>((done) => { resolve = done; }));
api.config.mockResolvedValue(config);
const view = await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
if (closeBeforeLoad) {
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
}
await act(async () => resolve(config));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
if (!closeBeforeLoad) {
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
}
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
view.unmount();
await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('consumes only the static greeting on avatar entry and leaves a real unread check-in until explicitly viewed', async () => {
api.config.mockResolvedValue({ enabled: true, definition: { ...definition, welcome_message: '欢迎一起创作' } });
api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' });
api.read.mockResolvedValue(checkInTopic());
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.getByTestId('teacher-invitation')).toHaveAttribute('data-bubble-kind', 'check-in');
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '看看智能体说的' })));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })));
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('keeps the elapsed interval when the Renderer reloads instead of starting another five-minute wait', async () => {
const first = await mount();
await advance(4 * 60_000);
first.unmount();
await mount();
await advance(60_000);
expect(api.checkIn).toHaveBeenCalledTimes(1);
});
it('reconciles a pending reply after losing its event stream without another model call', async () => {
const pending = checkInTopic();
pending.revision = 1;
pending.requests[0] = { ...pending.requests[0], status: 'running', response: '' };
api.checkIn.mockResolvedValue({ topic: pending });
api.events.mockRejectedValue(new Error('stream disconnected'));
api.read.mockResolvedValue(checkInTopic());
await mount();
await advance();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await advance(15_000);
expect(api.read).toHaveBeenCalledWith('project', 'topic-project', false);
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(api.checkIn).toHaveBeenCalledTimes(1);
await advance(15_000);
expect(api.read).toHaveBeenCalledTimes(1);
});
it('checks again after a busy skip without making the student wait another full interval', async () => {
api.checkIn.mockResolvedValueOnce({ topic: null, skipped: 'busy' });
await mount();
await advance();
await advance(15_000);
expect(api.checkIn).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
});
it('keeps the pixel teacher in the header and only shows its companion avatar with a real message', async () => {
await mount();
expect(screen.getByText(definition.name)).toBeVisible();
const headerAvatar = screen.getByRole('button', { name: '智能体', exact: true }).querySelector('img');
expect(headerAvatar?.src).toContain('avatar-06.png');
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: '智能体偶尔来看看' })).not.toBeInTheDocument();
expect(api.checkIn).not.toHaveBeenCalled();
await advance(TEACHER_CHECK_IN_INTERVAL_MS - 1);
expect(api.checkIn).not.toHaveBeenCalled();
await advance(1);
expect(api.checkIn).toHaveBeenCalledWith('project', expect.objectContaining({ sourceConversationId: 'source', requestId: expect.any(String) }));
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
expect(screen.getByTestId('teacher-presence')).toBeVisible();
expect(screen.getByRole('button', { name: '打开这条智能体消息' }).querySelector('img')?.src).toBe(headerAvatar?.src);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '和智能体聊聊' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toBeVisible();
const panel = screen.getByTestId('teacher-chat-panel');
expect(within(panel).getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
expect(panel.querySelector('.consultation-user')).toBeNull();
expect(screen.getByLabelText('向智能体提问')).toHaveFocus();
expect(api.send).not.toHaveBeenCalled();
});
it('dismisses both the short bubble and its attached portrait without opening a conversation', async () => {
await mount(); await advance();
const presence = screen.getByTestId('teacher-presence');
expect(within(presence).getByTestId('teacher-invitation')).toBeVisible();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起智能体气泡' })));
expect(screen.queryByTestId('teacher-presence')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '智能体', exact: true })).toBeVisible();
expect(api.send).not.toHaveBeenCalled();
});
it('does not stack invitations and remembers dismissal when the project is reopened', async () => {
const view = await mount(); await advance(); await advance();
expect(api.checkIn).toHaveBeenCalledTimes(1);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '等会儿聊' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
view.unmount();
api.list.mockResolvedValue({ items: [{ id: 'topic-project' }], lastSelectedTopicId: 'topic-project' });
api.read.mockResolvedValue(checkInTopic());
await mount();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.checkIn).toHaveBeenCalledTimes(1);
});
it('waits for a teacher draft, then keeps the bubble visible even with consultation open until explicitly read', async () => {
await mount();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })); });
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还在想怎么说' } });
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还在想怎么说');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
await advance(15_000);
expect(within(screen.getByTestId('teacher-chat-panel')).getByText('你刚才想加排行榜,我们一起想想比什么更有意思?')).toBeVisible();
expect(screen.getByTestId('teacher-invitation')).toBeVisible();
await advance();
expect(api.checkIn).toHaveBeenCalledTimes(1);
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '看看智能体说的' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
});
it('retains an unread check-in when a later student exchange follows it', async () => {
const topic = checkInTopic();
topic.requests.push({ ...topic.requests[0], id: 'question-2', intent: 'question', text: '另一件事', response: '我们继续聊。' });
api.list.mockResolvedValue({ items: [{ id: topic.id }], lastSelectedTopicId: topic.id });
api.read.mockResolvedValue(topic);
await mount();
expect(screen.getByTestId('teacher-invitation')).toHaveTextContent('排行榜');
await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '智能体', exact: true })); });
expect(screen.getByTestId('teacher-invitation')).toBeVisible();
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '等会儿聊' })); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
});
it('defers while hidden, while operation is running, or on an archived source', async () => {
const view = await mount({ busy: true }); await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => { view.rerender(<Harness archived />); }); await advance();
expect(api.checkIn).not.toHaveBeenCalled();
await act(async () => { view.rerender(<Harness />); });
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); await advance();
expect(api.checkIn).not.toHaveBeenCalled();
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
expect(api.checkIn).toHaveBeenCalledTimes(1);
});
it('rechecks the selected source after slow configuration loading and does not replay an old-source request', async () => {
const view = await mount();
let finish!: (value: {enabled: boolean; definition: TeacherDefinition}) => void;
api.config.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
localStorage.setItem('makelore-teacher-presence:kid:project:pending', JSON.stringify({ requestId: 'old-request', sourceConversationId: 'source' }));
await advance();
await act(async () => { view.rerender(<Harness sourceId="new-source" />); });
await act(async () => { finish({ enabled: true, definition }); });
expect(api.checkIn).toHaveBeenCalledWith('project', expect.objectContaining({ sourceConversationId: 'new-source' }));
expect(api.checkIn.mock.calls[0][1].requestId).not.toBe('old-request');
});
it('ignores a late old-project reply and preserves the request identity after unknown delivery', async () => {
let finish!: (value: { topic: TeacherTopic }) => void;
api.checkIn.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
const view = await mount(); await advance();
await act(async () => { view.rerender(<Harness projectId="other" />); });
await act(async () => { finish({ topic: checkInTopic() }); });
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
api.checkIn.mockRejectedValueOnce(new Error('timeout'));
await advance();
const uncertain = api.checkIn.mock.calls.at(-1)?.[1];
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await advance();
expect(api.checkIn.mock.calls.at(-1)?.[1]).toEqual(uncertain);
});
});