432 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|