Files
makelore/tests/unit/teacher-companion.test.tsx

951 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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 { codingConversationStore } from '@/stores/coding-conversations';
import type { ConversationRunState } from '@/types/coding-conversation';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
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() }));
const identity = vi.hoisted(() => ({ id: 'kid' }));
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, legacyTopicBase: (base: string) => base, teacherTopicsPath: (project: string) => project,
teacherConversationsPath: (project: string) => '/api/coding/projects/' + project + '/agent-conversations' }));
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: { userId: identity.id } }) }));
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();
codingConversationStore.setState({ entriesByConversationId: {}, summariesByConversationId: {} });
identity.id = 'kid';
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('grouped teacher introductions', () => {
const agents = ['方法老师', '体验老师', '故事老师'].map((name, index) => ({
teacher_id: `intro-${index}`, version: 1, is_default: index === 0,
definition: { ...definition, config_id: `intro-${index}`, name, description: `${name}的自我介绍`, welcome_message: `${name}的聊天欢迎语` },
}));
async function operation(runId: string, overrides: Partial<ConversationRunState> = {}, projectId = 'project', sourceId = 'source') {
const snapshot = createProductSnapshot(sourceId);
snapshot.conversation.projectId = projectId;
snapshot.run = { status: 'idle', mode: 'prompt', runId, startedAt: Date.now(), settledAt: Date.now(), terminalReason: 'completed', ...overrides };
snapshot.cursor.seq = (codingConversationStore.getState().entriesByConversationId[sourceId]?.reducer.snapshot?.cursor.seq ?? 0) + 1;
await act(async () => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot', conversationId: sourceId, workerGeneration: 1, seq: snapshot.cursor.seq, snapshot,
}));
}
it('leaves introductions until the third completed operation round and preserves hover and unread receipts', async () => {
api.catalog.mockResolvedValue({ items: agents });
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
await mount();
await operation('one');
await operation('one'); // A duplicate/recovered completion is still the same round.
await operation('two');
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
await operation('three', { status: 'running', terminalReason: undefined });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' });
expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍');
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('ignores historical, failed, aborted, tool/retry, compaction and other-project activity', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
for (let i = 0; i < 3; i++) {
await operation(`history-${i}`, { startedAt: Date.now() - 60_000 });
await operation(`failed-${i}`, { status: 'error', terminalReason: 'failed' });
await operation(`aborted-${i}`, { terminalReason: 'aborted' });
await operation(`retry-${i}`, { status: 'retrying', terminalReason: undefined });
await operation(`tools-${i}`, { status: 'running', terminalReason: undefined });
await operation(`compaction-${i}`, { mode: undefined });
await operation(`other-project-${i}`, {}, 'other');
}
await operation('one');
await operation('two');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('continues the count across reload and operation-chat switches, including a completion during reload', async () => {
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
await operation('same-run-id');
await act(async () => first.rerender(<Harness sourceId="second-source" />));
await operation('same-run-id', {}, 'project', 'second-source');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
first.unmount();
const second = await mount({ sourceId: 'second-source' });
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
second.unmount();
await operation('third-during-reload', {}, 'project', 'source');
const third = await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
third.unmount();
await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('keeps project/account windows independent without reopening for newly delivered teachers', async () => {
api.catalog.mockResolvedValue({ items: agents });
const view = await mount();
await operation('one');
await operation('two');
await advance(1);
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-one', {}, 'other');
await act(async () => view.rerender(<Harness />));
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => view.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-two', {}, 'other');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('other-three', {}, 'other');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await advance(1);
identity.id = 'new-kid';
await act(async () => view.rerender(<Harness />));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
identity.id = 'kid';
await act(async () => view.rerender(<Harness />));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师' } };
api.catalog.mockResolvedValue({ items: [...agents, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
it('keeps the entire group dismissed after teacher turns and closing consultation', async () => {
api.catalog.mockResolvedValue({ items: agents });
const topic = { ...checkInTopic(), definition: agents[0].definition, requests: [],
conversation: { agentId: agents[0].teacher_id, segmentTurns: 0, discussions: {} } };
api.sendConversation.mockResolvedValue(topic);
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '和方法老师聊聊' })));
for (let i = 0; i < 3; i++) {
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: `老师问题${i}` } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
}
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
await operation('one'); await operation('two'); await operation('three');
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(api.sendConversation).toHaveBeenCalledTimes(3);
});
it('introduces every delivered teacher independently of the old shared welcome dismissal without sending requests', async () => {
localStorage.setItem('makelore-teacher-presence:kid:welcome-dismissed', 'true');
api.catalog.mockResolvedValue({ items: agents });
await mount();
const group = screen.getByTestId('teacher-welcomes');
expect(within(group).queryByText('来认识一下')).not.toBeInTheDocument();
expect(within(group).queryByRole('button', { name: '先自己试试' })).not.toBeInTheDocument();
for (const agent of agents) expect(within(group).getByText(agent.definition.description)).toBeVisible();
expect(screen.queryByTestId('teacher-invitation')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(体验老师)' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-chat-panel')).not.toBeInTheDocument();
await advance();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
expect(api.seenConversation).not.toHaveBeenCalled();
});
it('keeps dismissal across reloads for this account/project while leaving other projects and accounts independent', async () => {
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })));
first.unmount();
const next = await mount();
expect(screen.queryByRole('button', { name: '收起全部老师介绍(方法老师)' })).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })).toBeVisible();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => next.rerender(<Harness />));
expect(screen.queryByRole('button', { name: '收起全部老师介绍(方法老师)' })).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
identity.id = 'another-kid';
await act(async () => next.rerender(<Harness />));
expect(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })).toBeVisible();
});
it('opens the clicked teacher and consumes the whole group', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
await act(async () => fireEvent.click(within(screen.getByTestId('teacher-welcomes')).getByText('故事老师的自我介绍')));
expect(screen.getByRole('button', { name: '与故事老师聊天' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('teacher-chat-panel')).toHaveTextContent('故事老师的聊天欢迎语');
expect(screen.getByTestId('teacher-chat-panel')).not.toHaveTextContent('故事老师的自我介绍');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与故事老师聊天' })));
expect(screen.queryByRole('button', { name: '收起全部老师介绍(故事老师)' })).not.toBeInTheDocument();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('keeps refreshed and newly delivered teachers dismissed without resetting seen replies', async () => {
localStorage.setItem('makelore-teacher-presence:kid:seen', JSON.stringify(['keep-seen']));
api.catalog.mockResolvedValue({ items: agents });
await mount();
await act(async () => fireEvent.click(screen.getByRole('button', { name: '收起全部老师介绍(方法老师)' })));
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师', description: '', welcome_message: '新老师的云端欢迎语' } };
api.catalog.mockResolvedValue({ items: [...agents.map(agent => ({ ...agent, version: 2 })), added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
for (const agent of agents) expect(screen.queryByRole('button', { name: `收起全部老师介绍(${agent.definition.name})` })).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('makelore-teacher-presence:kid:seen')!)).toEqual(['keep-seen']);
expect(api.sendConversation).not.toHaveBeenCalled();
expect(api.checkIn).not.toHaveBeenCalled();
});
it('adds every new teacher to the visible group without resetting the shared count', async () => {
api.catalog.mockResolvedValue({ items: agents });
await mount();
await operation('one'); await operation('two');
const added = { ...agents[0], teacher_id: 'new', definition: { ...agents[0].definition, config_id: 'new', name: '新老师' } };
api.catalog.mockResolvedValue({ items: [...agents, added] });
await act(async () => fireEvent.click(screen.getByRole('button', { name: '刷新智能体' })));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(4);
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
});
const legacyKeys = [
'makelore-teacher-presence:kid:project:project:introduced-agents',
'makelore-teacher-presence:kid:project:project:project:introduced-agents',
];
it.each(legacyKeys)('migrates partial dismissal from %s to the entire group without changing old data', async (key) => {
const old = JSON.stringify(['intro-1']);
localStorage.setItem(key, old);
api.catalog.mockResolvedValue({ items: agents });
const first = await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
fireEvent.pointerEnter(screen.getByRole('button', { name: '与方法老师聊天' }), { pointerType: 'mouse' });
expect(screen.getByRole('tooltip')).toHaveTextContent('方法老师的自我介绍');
first.unmount();
const next = await mount();
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(localStorage.getItem(key)).toBe(old);
await act(async () => next.rerender(<Harness projectId="other" />));
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
});
it.each(legacyKeys)('continues legacy operation counts from %s without discarding history', async (key) => {
const roundsKey = key + ':rounds';
const rounds = JSON.stringify(Object.fromEntries(agents.map(agent => [agent.teacher_id, {
since: Date.now() - 1000, runs: [JSON.stringify(['source', 'one']), JSON.stringify(['source', 'two'])],
}])));
localStorage.setItem(roundsKey, rounds);
api.catalog.mockResolvedValue({ items: agents });
await mount();
expect(screen.getByTestId('teacher-welcomes').querySelectorAll('.teacher-welcome-card')).toHaveLength(3);
await operation('two');
expect(screen.getByTestId('teacher-welcomes')).toBeVisible();
await operation('three');
expect(screen.queryByTestId('teacher-welcomes')).not.toBeInTheDocument();
expect(localStorage.getItem(roundsKey)).toBe(rounds);
});
});
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('project', 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 (project, id) => ({ topic: project === 'project' && 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('向智能体提问')).toHaveValue('');
expect(screen.queryByText(history.requests[0].response)).toBeNull();
await act(async () => view.rerender(<Harness projectId="project" />));
const restoredInput = screen.getByLabelText('向智能体提问');
expect(restoredInput).toHaveValue('接着刚才说');
api.sendConversation.mockResolvedValue(history);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '提问', exact: true })));
expect(api.sendConversation).toHaveBeenCalledWith('project', original.teacher_id, expect.objectContaining({ text: '接着刚才说', projectId: 'project' }));
fireEvent.change(restoredInput, { 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(restoredInput).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:project:selected-agent', JSON.stringify('disabled'));
api.conversation.mockResolvedValue({topic: saved, before: null});
api.seenConversation.mockResolvedValue(saved);
await mount();
expect(api.conversation).toHaveBeenCalledWith('project', '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 and selected agents separately for each project', 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 (project, id) => ({topic: project === 'project' ? chats.find(chat => chat.definition.config_id === id) : null, before: null}));
api.seenConversation.mockImplementation(async (_project, 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('project', '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: '与a聊天'})).toHaveAttribute('aria-pressed', 'true');
expect(view.container.querySelectorAll('.teacher-companion-unread')).toHaveLength(0);
await act(async () => view.rerender(<Harness projectId="project" />));
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('project', '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 (_project, 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('project', '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][1]).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('project', '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((_project, 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('project', '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 without a model request and remembers dismissal in its project', 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.getByTestId('teacher-invitation')).toBeVisible();
await act(async () => next.rerender(<Harness projectId="project" />));
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 within its project', 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.getByTestId('teacher-invitation')).toBeVisible();
await act(async () => next.rerender(<Harness projectId="project" />));
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);
});
});