Files
makelore/tests/unit/learning-course-player.test.ts
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

386 lines
15 KiB
TypeScript

import { createElement } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { sanitizeLearningRuntimeAnchor } from '@/lib/learning-runtime-anchor';
import { matchesLearningCloudProgress } from '@/pages/Learning/CoursePlayer';
import { LearningCoursePlayer } from '@/pages/Learning/CoursePlayer';
import { useAuthStore } from '@/stores/auth';
import type { LearningClassroomPayload } from '../../shared/learning';
const getLearningPlayerUrlMock = vi.hoisted(() => vi.fn());
const readLearningClassroomMock = vi.hoisted(() => vi.fn());
const fetchLearningProgressMock = vi.hoisted(() => vi.fn());
const saveLearningProgressMock = vi.hoisted(() => vi.fn());
const askLearningAgentMock = vi.hoisted(() => vi.fn());
const transcribeLearningSpeechMock = vi.hoisted(() => vi.fn());
const streamLearningRuntimeMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/learning', () => ({
getLearningPlayerUrl: getLearningPlayerUrlMock,
readLearningClassroom: readLearningClassroomMock,
fetchLearningProgress: fetchLearningProgressMock,
saveLearningProgress: saveLearningProgressMock,
askLearningAgent: askLearningAgentMock,
transcribeLearningSpeech: transcribeLearningSpeechMock,
streamLearningRuntime: streamLearningRuntimeMock,
}));
function classroomPayload(moduleId = 'module-a'): LearningClassroomPayload {
const modules = ['module-a', 'module-b', 'module-c'].map((id, index) => ({
moduleId: id,
title: `Module ${String.fromCharCode(65 + index)}`,
summary: null,
sceneCount: 1,
contentHash: `${index + 1}`.repeat(64),
}));
const selectedModule = modules.find((module) => module.moduleId === moduleId)!;
return {
courseId: 'course-1',
courseContentHash: 'a'.repeat(64),
contentHash: 'a'.repeat(64),
moduleId,
moduleContentHash: selectedModule.contentHash,
modules,
classroom: { stage: { title: selectedModule.title }, scenes: [] },
};
}
function renderPlayer() {
return render(createElement(
MemoryRouter,
{ initialEntries: ['/learning/course/course-1'] },
createElement(
Routes,
null,
createElement(Route, {
path: '/learning/course/:courseId',
element: createElement(LearningCoursePlayer),
}),
createElement(Route, {
path: '/learning',
element: createElement('div', { 'data-testid': 'learning-library' }),
}),
),
));
}
describe('Learning CoursePlayer runtime boundary', () => {
beforeEach(() => {
getLearningPlayerUrlMock.mockReset();
readLearningClassroomMock.mockReset();
fetchLearningProgressMock.mockReset();
saveLearningProgressMock.mockReset();
askLearningAgentMock.mockReset();
transcribeLearningSpeechMock.mockReset();
streamLearningRuntimeMock.mockReset();
window.localStorage.clear();
useAuthStore.setState({
user: {
username: 'account-a',
userId: 'account-a',
tenantId: null,
deptId: null,
authorities: [],
},
});
getLearningPlayerUrlMock.mockResolvedValue('http://127.0.0.1:43123/player/');
readLearningClassroomMock.mockResolvedValue(classroomPayload());
fetchLearningProgressMock.mockResolvedValue([]);
saveLearningProgressMock.mockResolvedValue({});
});
it('copies only sceneId and sceneOrder from the untrusted iframe anchor', () => {
expect(sanitizeLearningRuntimeAnchor({
sceneId: 'scene-3',
sceneOrder: 3,
courseId: 'iframe-course',
contentHash: 'iframe-hash',
moduleId: 'iframe-module',
moduleContentHash: 'iframe-module-hash',
actionIndex: 8,
})).toEqual({ sceneId: 'scene-3', sceneOrder: 3 });
});
it('drops invalid or empty anchors instead of forwarding arbitrary context', () => {
expect(sanitizeLearningRuntimeAnchor({ sceneId: '', sceneOrder: -1, arbitrary: true })).toBeUndefined();
expect(sanitizeLearningRuntimeAnchor(['scene-1'])).toBeUndefined();
});
it('restores single-course cloud progress stored under the server main module', () => {
expect(matchesLearningCloudProgress(
{ courseId: 'course-1', contentHash: 'a'.repeat(64), moduleId: 'main' },
'course-1',
{ courseContentHash: 'a'.repeat(64), moduleId: null, modules: [{}] },
)).toBe(true);
expect(matchesLearningCloudProgress(
{ courseId: 'course-1', contentHash: 'a'.repeat(64), moduleId: 'other' },
'course-1',
{ courseContentHash: 'a'.repeat(64), moduleId: null, modules: [{}] },
)).toBe(false);
});
it('loads the iframe player and sends only the verified classroom payload after its ready bridge event', async () => {
renderPlayer();
const iframe = await screen.findByTitle('Makelore 互动课程');
expect(iframe).toHaveAttribute('src', 'http://127.0.0.1:43123/player/');
expect(readLearningClassroomMock).toHaveBeenCalledWith('course-1');
const playerWindow = (iframe as HTMLIFrameElement).contentWindow!;
const postMessage = vi.spyOn(playerWindow, 'postMessage');
fireEvent.load(iframe);
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://attacker.invalid',
data: { type: 'makelore:player:ready' },
}));
});
expect(postMessage).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://127.0.0.1:43123',
data: { type: 'makelore:player:ready' },
}));
});
await waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'makelore:course:load',
courseId: 'course-1',
moduleId: 'module-a',
}),
'http://127.0.0.1:43123',
));
expect(postMessage.mock.calls.every((call) => call[1] !== '*')).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '返回课程库' }));
expect(await screen.findByTestId('learning-library')).toBeInTheDocument();
});
it('requires the current iframe source and exact origin before invoking a privileged bridge', async () => {
askLearningAgentMock.mockResolvedValue({ text: 'answer' });
renderPlayer();
const iframe = await screen.findByTitle('Makelore 互动课程');
const playerWindow = (iframe as HTMLIFrameElement).contentWindow!;
fireEvent.load(iframe);
const request = {
type: 'makelore:agent:request',
requestId: 'request-1',
request: { message: 'Help me' },
};
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://attacker.invalid',
data: request,
}));
window.dispatchEvent(new MessageEvent('message', {
source: window,
origin: 'http://127.0.0.1:43123',
data: request,
}));
});
expect(askLearningAgentMock).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://127.0.0.1:43123',
data: request,
}));
});
await waitFor(() => expect(askLearningAgentMock).toHaveBeenCalledWith(expect.objectContaining({
courseId: 'course-1',
contentHash: 'a'.repeat(64),
message: 'Help me',
})));
});
it('does not deliver an asynchronous reply after the iframe navigates', async () => {
let resolveAgent!: (value: { text: string }) => void;
askLearningAgentMock.mockReturnValue(new Promise((resolve) => { resolveAgent = resolve; }));
renderPlayer();
const iframe = await screen.findByTitle('Makelore 互动课程');
const playerWindow = (iframe as HTMLIFrameElement).contentWindow!;
const postMessage = vi.spyOn(playerWindow, 'postMessage');
fireEvent.load(iframe);
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://127.0.0.1:43123',
data: {
type: 'makelore:agent:request',
requestId: 'request-before-navigation',
request: { message: 'Help me' },
},
}));
});
await waitFor(() => expect(askLearningAgentMock).toHaveBeenCalledTimes(1));
fireEvent.load(iframe);
expect(await screen.findByRole('alert')).toHaveTextContent('课程播放器已离开受信任页面');
await act(async () => { resolveAgent({ text: 'stale answer' }); });
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'makelore:agent:response' }),
expect.anything(),
);
});
it('permanently rejects every bridge capability after the iframe navigates', async () => {
askLearningAgentMock.mockResolvedValue({ text: 'answer' });
transcribeLearningSpeechMock.mockResolvedValue({ text: 'transcript' });
renderPlayer();
const iframe = await screen.findByTitle('Makelore 互动课程');
const playerWindow = (iframe as HTMLIFrameElement).contentWindow!;
const postMessage = vi.spyOn(playerWindow, 'postMessage');
fireEvent.load(iframe);
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://127.0.0.1:43123',
data: { type: 'makelore:player:ready' },
}));
});
await waitFor(() => expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'makelore:course:load' }),
'http://127.0.0.1:43123',
));
postMessage.mockClear();
fireEvent.load(iframe);
expect(await screen.findByRole('alert')).toHaveTextContent('课程播放器已离开受信任页面');
act(() => {
const dispatch = (data: unknown) => window.dispatchEvent(new MessageEvent('message', {
source: playerWindow,
origin: 'http://127.0.0.1:43123',
data,
}));
dispatch({ type: 'makelore:player:ready' });
dispatch({ type: 'makelore:player:loaded' });
dispatch({ type: 'makelore:agent:request', requestId: 'agent-after-nav', request: { message: 'Help' } });
dispatch({
type: 'makelore:transcription:request',
requestId: 'asr-after-nav',
audio: new ArrayBuffer(4),
});
dispatch({
type: 'makelore:runtime:request',
requestId: 'runtime-after-nav',
capability: 'quiz-grade',
context: { anchor: { sceneId: 'scene-1' } },
body: {},
});
dispatch({ type: 'makelore:progress', sceneOrder: 2, completed: false });
});
await act(async () => Promise.resolve());
expect(askLearningAgentMock).not.toHaveBeenCalled();
expect(transcribeLearningSpeechMock).not.toHaveBeenCalled();
expect(streamLearningRuntimeMock).not.toHaveBeenCalled();
expect(saveLearningProgressMock).not.toHaveBeenCalled();
expect(postMessage).not.toHaveBeenCalled();
expect(window.localStorage.getItem(
`makelore.learning.progress.account-a.course-1.${'1'.repeat(64)}`,
)).toBeNull();
});
it('partitions local progress and reloads the complete player session when the account changes', async () => {
getLearningPlayerUrlMock
.mockResolvedValueOnce('http://127.0.0.1:43123/player-a/')
.mockResolvedValue('http://127.0.0.1:43124/player-b/');
renderPlayer();
const iframeA = await screen.findByTitle('Makelore 互动课程');
const playerWindowA = (iframeA as HTMLIFrameElement).contentWindow!;
fireEvent.load(iframeA);
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindowA,
origin: 'http://127.0.0.1:43123',
data: { type: 'makelore:progress', sceneOrder: 7, actionIndex: 3, completed: false },
}));
});
await waitFor(() => expect(saveLearningProgressMock).toHaveBeenCalledTimes(1));
expect(window.localStorage.getItem(
`makelore.learning.progress.account-a.course-1.${'1'.repeat(64)}`,
)).toContain('"sceneOrder":7');
expect(window.localStorage.getItem(
`makelore.learning.progress.account-b.course-1.${'1'.repeat(64)}`,
)).toBeNull();
act(() => {
useAuthStore.setState({
user: {
username: 'account-b',
userId: 'account-b',
tenantId: null,
deptId: null,
authorities: [],
},
});
});
await waitFor(() => expect(getLearningPlayerUrlMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(readLearningClassroomMock).toHaveBeenCalledTimes(2));
const iframeB = await waitFor(() => {
const iframe = screen.getByTitle('Makelore 互动课程');
expect(iframe).toHaveAttribute('src', 'http://127.0.0.1:43124/player-b/');
return iframe;
});
expect(iframeB).not.toBe(iframeA);
const playerWindowB = (iframeB as HTMLIFrameElement).contentWindow!;
const postMessageB = vi.spyOn(playerWindowB, 'postMessage');
fireEvent.load(iframeB);
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: playerWindowB,
origin: 'http://127.0.0.1:43124',
data: { type: 'makelore:player:ready' },
}));
window.dispatchEvent(new MessageEvent('message', {
source: playerWindowA,
origin: 'http://127.0.0.1:43123',
data: { type: 'makelore:progress', sceneOrder: 9, completed: true },
}));
});
await waitFor(() => expect(postMessageB).toHaveBeenCalledWith(
expect.objectContaining({ type: 'makelore:course:load', progress: null }),
'http://127.0.0.1:43124',
));
expect(saveLearningProgressMock).toHaveBeenCalledTimes(1);
expect(getLearningPlayerUrlMock).toHaveBeenCalledTimes(2);
expect(readLearningClassroomMock).toHaveBeenCalledTimes(2);
expect(fetchLearningProgressMock).toHaveBeenCalledTimes(2);
});
it('ignores a stale module response that finishes after the latest selection', async () => {
let resolveModuleB!: (payload: LearningClassroomPayload) => void;
let resolveModuleC!: (payload: LearningClassroomPayload) => void;
const moduleB = new Promise<LearningClassroomPayload>((resolve) => { resolveModuleB = resolve; });
const moduleC = new Promise<LearningClassroomPayload>((resolve) => { resolveModuleC = resolve; });
readLearningClassroomMock.mockImplementation((_courseId: string, moduleId?: string) => {
if (!moduleId) return Promise.resolve(classroomPayload());
if (moduleId === 'module-b') return moduleB;
if (moduleId === 'module-c') return moduleC;
throw new Error(`Unexpected module ${moduleId}`);
});
renderPlayer();
fireEvent.click(await screen.findByRole('button', { name: '2. Module B' }));
fireEvent.click(screen.getByRole('button', { name: '3. Module C' }));
await act(async () => { resolveModuleC(classroomPayload('module-c')); });
await waitFor(() => expect(screen.getByRole('button', { name: '3. Module C' })).toHaveAttribute('aria-current', 'page'));
await act(async () => { resolveModuleB(classroomPayload('module-b')); });
expect(screen.getByRole('button', { name: '3. Module C' })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: '2. Module B' })).not.toHaveAttribute('aria-current');
});
});