224 lines
15 KiB
TypeScript
224 lines
15 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 TeacherDefinition, type TeacherTopic } from '../../shared/coding-teacher';
|
|
|
|
const api = vi.hoisted(() => ({ config: vi.fn(), list: vi.fn(), read: vi.fn(), create: vi.fn(), send: vi.fn(), events: vi.fn(), checkIn: vi.fn() }));
|
|
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, 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={() => setOpen(true)} />
|
|
{open && <TeacherChatPanel key={projectId} projectId={projectId} sourceId="source" role="teacher" externalTopic={companion.topic} onTopicChange={companion.noteTopic} onComposingChange={setTeacherComposing} onClose={() => setOpen(false)} />}</>;
|
|
}
|
|
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();
|
|
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
|
api.config.mockResolvedValue({ enabled: true, definition });
|
|
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(); });
|
|
|
|
describe('teacher presence and periodic guidance', () => {
|
|
it('shows the published greeting immediately without a model request and remembers dismissal per 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')).toHaveAttribute('data-bubble-kind', 'welcome');
|
|
});
|
|
|
|
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('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('teacher-pixel.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);
|
|
});
|
|
});
|