merge: integrate remote learning module safely
This commit is contained in:
commit
f7171a471a
125 files changed
+11451
-12132
No files matched your search
@@ -0,0 +1,431 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user