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(() => ({ catalog: vi.fn(), 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, 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 <> { 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 && }; } async function mount(props: Parameters[0] = {}) { let view!: ReturnType; await act(async () => { view = render(); }); 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.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('teacher presence and periodic guidance', () => { 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.send.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).toHaveBeenCalledWith('project', undefined, undefined, 9); expect(api.send.mock.calls[0][2].text).toBe('体验的草稿'); }); it('restores the selected agent history at its pinned version instead of the globally last topic', 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', version: 1, definition: a.definition, requests: [] }; const oldB = { ...checkInTopic(), id: 'b-topic', 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.read.mockImplementation(async (_base, id) => id === oldA.id ? oldA : oldB); api.send.mockResolvedValue({ ...oldA, revision: 3 }); await mount(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与方法老师聊天' }))); expect(api.read).toHaveBeenCalledWith('project', oldA.id); 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.send.mock.calls[0][1]).toBe(oldA.id); expect(api.create).not.toHaveBeenCalled(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible(); expect(api.read).toHaveBeenCalledWith('project', oldB.id); }); 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!: (topic: TeacherTopic) => void; api.read.mockReturnValue(new Promise((done) => { resolve = done; })); await mount(); await act(async () => fireEvent.click(screen.getByRole('button', { name: '与体验朋友聊天' }))); await act(async () => resolve(old)); expect(screen.getByRole('complementary', { name: '体验朋友咨询' })).toBeVisible(); expect(screen.getByRole('button', { name: '与体验朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); expect(api.read).toHaveBeenCalledWith('project', old.id, false); 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 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()); 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('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(); }); await advance(); expect(api.checkIn).not.toHaveBeenCalled(); await act(async () => { view.rerender(); }); 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(); }); 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(); }); 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); }); });