Files
makelore/tests/unit/learning-page.test.tsx
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

432 lines
16 KiB
TypeScript

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(<MemoryRouter><Learning /></MemoryRouter>);
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(<MemoryRouter><Learning /></MemoryRouter>);
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(<MemoryRouter initialEntries={['/learning?generate=1']}><Learning /></MemoryRouter>);
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(<MemoryRouter initialEntries={['/learning?generate=1']}><Learning /></MemoryRouter>);
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(
<GenerationWorkbench
onInstalled={vi.fn()}
onOpenCourse={vi.fn()}
/>,
);
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(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
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(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
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<ArrayBuffer>();
const material = new File(['private'], 'account-a.txt', { type: 'text/plain' });
vi.spyOn(material, 'arrayBuffer').mockReturnValue(fileRead.promise);
render(<GenerationWorkbench onInstalled={vi.fn()} onOpenCourse={vi.fn()} />);
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(
<GenerationWorkbench
onInstalled={onInstalled}
onOpenCourse={vi.fn()}
/>,
);
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(
<GenerationWorkbench
onInstalled={vi.fn()}
onOpenCourse={vi.fn()}
/>,
);
expect(await screen.findByTestId('learning-generation-task')).toHaveTextContent('job-a');
first.unmount();
render(
<GenerationWorkbench
onInstalled={vi.fn()}
onOpenCourse={vi.fn()}
/>,
);
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(<MemoryRouter><Learning /></MemoryRouter>);
await screen.findByText('暂时还没有已发布课程');
expect(screen.getByRole('region', { name: '课程广场' })).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
});
});