import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Learning } from '@/pages/Learning'; import { GenerationWorkbench, LEARNING_GENERATION_TASK_STORAGE_KEY, } from '@/pages/Learning/GenerationWorkbench'; import { useAuthStore } from '@/stores/auth'; import { MemoryRouter } from 'react-router-dom'; const fetchLearningCoursesMock = vi.hoisted(() => vi.fn()); const fetchMyLearningCoursesMock = vi.hoisted(() => vi.fn()); const listInstalledLearningCoursesMock = vi.hoisted(() => vi.fn()); const downloadLearningCourseMock = vi.hoisted(() => vi.fn()); const startLearningGenerationMock = vi.hoisted(() => vi.fn()); const fetchLearningGenerationMock = vi.hoisted(() => vi.fn()); const finalizeLearningGenerationMock = vi.hoisted(() => vi.fn()); const cancelLearningGenerationMock = vi.hoisted(() => vi.fn()); const resumeLearningGenerationMock = vi.hoisted(() => vi.fn()); const transcribeLearningSpeechMock = vi.hoisted(() => vi.fn()); const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices'); class MockMediaRecorder { static latest: MockMediaRecorder | null = null; state: RecordingState = 'inactive'; mimeType = 'audio/webm'; ondataavailable: ((event: BlobEvent) => void) | null = null; onstop: (() => void) | null = null; constructor(_stream: MediaStream) { MockMediaRecorder.latest = this; } start = vi.fn(() => { this.state = 'recording'; }); stop = vi.fn(() => { this.state = 'inactive'; this.onstop?.(); }); emit(data: Blob): void { this.ondataavailable?.({ data } as BlobEvent); } } function generationTaskStorageKey(accountKey: string): string { return `${LEARNING_GENERATION_TASK_STORAGE_KEY}:${encodeURIComponent(accountKey)}`; } function persistedTask(jobId: string, phase = 'paused') { return { schemaVersion: 1, jobId, phase, generation: { contractVersion: 2, jobId, status: phase === 'paused' ? 'paused' : 'running', mode: 'single', done: false, }, courseId: null, updatedAt: '2026-08-16T00:00:00.000Z', }; } function setAuthAccount(userId: string | null, username = userId ?? ''): void { useAuthStore.setState({ initialized: true, loading: false, error: null, user: userId === null ? null : { username, userId, tenantId: null, deptId: null, authorities: [], }, }); } vi.mock('@/lib/learning', () => ({ DEFAULT_LEARNING_GENERATION_OPTIONS: { enableWebSearch: false, enableImageGeneration: false, enableVideoGeneration: false, enableTTS: true, interactiveMode: true, taskEngineMode: false, }, fetchLearningCourses: fetchLearningCoursesMock, fetchMyLearningCourses: fetchMyLearningCoursesMock, listInstalledLearningCourses: listInstalledLearningCoursesMock, downloadLearningCourse: downloadLearningCourseMock, startLearningGeneration: startLearningGenerationMock, fetchLearningGeneration: fetchLearningGenerationMock, finalizeLearningGeneration: finalizeLearningGenerationMock, cancelLearningGeneration: cancelLearningGenerationMock, resumeLearningGeneration: resumeLearningGenerationMock, transcribeLearningSpeech: transcribeLearningSpeechMock, })); describe('Learning page', () => { beforeEach(() => { fetchLearningCoursesMock.mockReset(); fetchMyLearningCoursesMock.mockReset(); listInstalledLearningCoursesMock.mockReset(); downloadLearningCourseMock.mockReset(); startLearningGenerationMock.mockReset(); fetchLearningGenerationMock.mockReset(); finalizeLearningGenerationMock.mockReset(); cancelLearningGenerationMock.mockReset(); resumeLearningGenerationMock.mockReset(); transcribeLearningSpeechMock.mockReset(); window.localStorage.clear(); setAuthAccount('user-a'); listInstalledLearningCoursesMock.mockResolvedValue([]); fetchMyLearningCoursesMock.mockResolvedValue([]); }); afterEach(() => { vi.unstubAllGlobals(); MockMediaRecorder.latest = null; if (originalMediaDevices) { Object.defineProperty(navigator, 'mediaDevices', originalMediaDevices); } else { Reflect.deleteProperty(navigator, 'mediaDevices'); } }); it('renders published courses returned by Works Square', async () => { fetchLearningCoursesMock.mockResolvedValue([{ id: 'course-1', origin: 'ops_large', status: 'published', title: 'Python 基础入门', summary: '通过互动讲解掌握 Python。', language: 'zh-CN', contentHash: 'a'.repeat(64), archiveSha256: 'b'.repeat(64), archiveBytes: 1024, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 10, capabilities: { sceneKinds: ['slide', 'interactive', 'quiz'], hasAudio: false, hasWhiteboard: true, hasAgent: true }, createdAt: '2026-08-16T00:00:00Z', publishedAt: '2026-08-16T00:00:00Z', }]); render(); expect(screen.getByRole('status')).toHaveTextContent('正在读取课程'); expect(await screen.findByTestId('learning-course-course-1')).toHaveTextContent('Python 基础入门'); expect(screen.getByText('10 个学习场景')).toBeInTheDocument(); }); it('shows a retryable error state', async () => { fetchLearningCoursesMock.mockRejectedValue(new Error('连接失败')); render(); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('连接失败')); expect(screen.getByRole('button', { name: '重试' })).toBeEnabled(); }); it('opens the full single-course workbench only from the generate route', async () => { fetchLearningCoursesMock.mockResolvedValue([]); render(); await screen.findByText('暂时还没有已发布课程'); expect(screen.getByRole('dialog')).toHaveTextContent('今天想学会什么?'); expect(screen.getByLabelText('课程需求')).toBeEnabled(); expect(screen.getByRole('button', { name: '后台生成' })).toBeDisabled(); expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: '语音讲解' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '互动模式' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '职教任务' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '语音输入' })).toBeInTheDocument(); expect(screen.queryByText(/供应商选择/)).not.toBeInTheDocument(); }); it('submits every canonical option explicitly to the background job', async () => { fetchLearningCoursesMock.mockResolvedValue([]); startLearningGenerationMock.mockResolvedValue({ contractVersion: 2, jobId: 'job-1', status: 'queued', mode: 'single', done: false, }); fetchLearningGenerationMock.mockImplementation(() => new Promise(() => undefined)); render(); fireEvent.change(await screen.findByLabelText('课程需求'), { target: { value: '用互动练习教我二次函数' } }); fireEvent.click(screen.getByRole('button', { name: '联网搜索' })); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.click(screen.getByRole('button', { name: '职教任务' })); fireEvent.click(screen.getByRole('button', { name: '后台生成' })); await waitFor(() => expect(startLearningGenerationMock).toHaveBeenCalledWith({ requirement: '用互动练习教我二次函数', enableWebSearch: true, enableImageGeneration: true, enableVideoGeneration: false, enableTTS: true, interactiveMode: true, taskEngineMode: true, }, [])); }); it('switches generation ownership from account A to account B and ignores A poll results', async () => { const accountAPoll = Promise.withResolvers<{ contractVersion: number; jobId: string; status: string; mode: string; done: boolean; courseId: string; }>(); window.localStorage.setItem( generationTaskStorageKey('user-a'), JSON.stringify(persistedTask('job-a', 'generating')), ); window.localStorage.setItem( generationTaskStorageKey('user-b'), JSON.stringify(persistedTask('job-b')), ); fetchLearningGenerationMock.mockReturnValue(accountAPoll.promise); render( , ); expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a'); await waitFor(() => expect(fetchLearningGenerationMock).toHaveBeenCalledWith('job-a')); act(() => setAuthAccount('user-b')); await waitFor(() => expect(screen.getByTestId('learning-generation-task')).toHaveTextContent('job-b')); expect(screen.queryByText(/job-a/)).not.toBeInTheDocument(); await act(async () => { accountAPoll.resolve({ contractVersion: 2, jobId: 'job-a', status: 'succeeded', mode: 'single', done: true, courseId: 'course-a', }); await accountAPoll.promise; }); expect(screen.getByTestId('learning-generation-task')).toHaveTextContent('job-b'); expect(finalizeLearningGenerationMock).not.toHaveBeenCalled(); }); it('clears account A form materials options and active recording when switching to account B', async () => { const stopTrack = vi.fn(); const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }], }); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia }, }); vi.stubGlobal('MediaRecorder', MockMediaRecorder); render(); fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 的私有需求' } }); fireEvent.change(screen.getByTestId('learning-material-input'), { target: { files: [new File(['private'], 'account-a.txt', { type: 'text/plain' })] }, }); fireEvent.click(screen.getByRole('button', { name: '联网搜索' })); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); expect(await screen.findByRole('button', { name: '停止语音输入' })).toBeEnabled(); expect(screen.getByText(/account-a\.txt/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'true'); act(() => setAuthAccount('user-b')); await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue('')); expect(screen.queryByText(/account-a\.txt/)).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '联网搜索' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled(); expect(MockMediaRecorder.latest?.stop).toHaveBeenCalledTimes(1); expect(stopTrack).toHaveBeenCalled(); expect(transcribeLearningSpeechMock).not.toHaveBeenCalled(); }); it('drops account A transcription that resolves after switching to account B', async () => { const transcription = Promise.withResolvers<{ text: string }>(); const stopTrack = vi.fn(); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }) }, }); vi.stubGlobal('MediaRecorder', MockMediaRecorder); transcribeLearningSpeechMock.mockReturnValue(transcription.promise); render(); fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 已有文本' } }); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); expect(await screen.findByRole('button', { name: '停止语音输入' })).toBeEnabled(); act(() => MockMediaRecorder.latest?.emit(new Blob(['voice'], { type: 'audio/webm' }))); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(transcribeLearningSpeechMock).toHaveBeenCalledTimes(1)); act(() => setAuthAccount('user-b')); await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue('')); await act(async () => { transcription.resolve({ text: '不应写入账号 B' }); await transcription.promise; }); expect(screen.getByLabelText('课程需求')).toHaveValue(''); expect(screen.queryByText('不应写入账号 B')).not.toBeInTheDocument(); }); it('does not upload account A material after its file read finishes under account B', async () => { const fileRead = Promise.withResolvers(); const material = new File(['private'], 'account-a.txt', { type: 'text/plain' }); vi.spyOn(material, 'arrayBuffer').mockReturnValue(fileRead.promise); render(); fireEvent.change(screen.getByLabelText('课程需求'), { target: { value: '账号 A 的私有需求' } }); fireEvent.change(screen.getByTestId('learning-material-input'), { target: { files: [material] } }); fireEvent.click(screen.getByRole('button', { name: '后台生成' })); await waitFor(() => expect(material.arrayBuffer).toHaveBeenCalledTimes(1)); act(() => setAuthAccount('user-b')); await waitFor(() => expect(screen.getByLabelText('课程需求')).toHaveValue('')); await act(async () => { fileRead.resolve(new Uint8Array([1, 2, 3]).buffer); await fileRead.promise; }); expect(startLearningGenerationMock).not.toHaveBeenCalled(); expect(screen.getByLabelText('课程需求')).toHaveValue(''); }); it('does not persist or finalize a stale poll result after unmount', async () => { const poll = Promise.withResolvers<{ contractVersion: number; jobId: string; status: string; mode: string; done: boolean; courseId: string; }>(); const storageKey = generationTaskStorageKey('user-a'); window.localStorage.setItem(storageKey, JSON.stringify(persistedTask('job-a', 'generating'))); fetchLearningGenerationMock.mockReturnValue(poll.promise); const onInstalled = vi.fn(); const view = render( , ); await waitFor(() => expect(fetchLearningGenerationMock).toHaveBeenCalledWith('job-a')); view.unmount(); await act(async () => { poll.resolve({ contractVersion: 2, jobId: 'job-a', status: 'succeeded', mode: 'single', done: true, courseId: 'course-a', }); await poll.promise; }); expect(JSON.parse(window.localStorage.getItem(storageKey) || '{}')).toMatchObject({ jobId: 'job-a', phase: 'generating', }); expect(finalizeLearningGenerationMock).not.toHaveBeenCalled(); expect(onInstalled).not.toHaveBeenCalled(); }); it('restores the persisted generation task when the same account remounts', async () => { const storageKey = generationTaskStorageKey('user-a'); window.localStorage.setItem(storageKey, JSON.stringify(persistedTask('job-a'))); const first = render( , ); expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a'); first.unmount(); render( , ); expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a'); expect(window.localStorage.getItem(LEARNING_GENERATION_TASK_STORAGE_KEY)).toBeNull(); }); it('renders the course plaza without adding another sidebar', async () => { fetchLearningCoursesMock.mockResolvedValue([]); render(); await screen.findByText('暂时还没有已发布课程'); expect(screen.getByRole('region', { name: '课程广场' })).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); }); });