feat: integrate learning module
This commit is contained in:
268
tests/unit/learning-course-library.test.ts
Normal file
268
tests/unit/learning-course-library.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { createLearningCourseLibrary } from '@electron/services/learning-course-library';
|
||||
import { createLearningPlayerServer } from '@electron/services/learning-player-server';
|
||||
|
||||
describe('Learning course library', () => {
|
||||
let root: string | null = null;
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'makelore-learning-library-'));
|
||||
fetchImpl.mockReset();
|
||||
getAccessToken.mockReset();
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function course(bytes: Uint8Array, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'course-1',
|
||||
origin: 'ops_large',
|
||||
status: 'published',
|
||||
title: 'Python 基础入门',
|
||||
summary: null,
|
||||
language: 'zh-CN',
|
||||
contentHash: 'a'.repeat(64),
|
||||
archiveSha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
archiveBytes: bytes.byteLength,
|
||||
formatVersion: 1,
|
||||
minPlayerVersion: '1.0.0',
|
||||
sceneCount: 10,
|
||||
capabilities: { sceneKinds: ['slide', 'interactive', 'quiz'], hasAudio: false, hasWhiteboard: true, hasAgent: true },
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
publishedAt: '2026-08-16T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('streams and installs a package only after hash and size verification', async () => {
|
||||
const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]);
|
||||
const metadata = course(archive);
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip' } }));
|
||||
const library = createLearningCourseLibrary({
|
||||
rootDirectory: root!,
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
now: () => new Date('2026-08-16T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
const installed = await library.download('course-1');
|
||||
|
||||
expect(new Uint8Array(await readFile(installed.archivePath))).toEqual(archive);
|
||||
expect(installed).toMatchObject({ schemaVersion: 1, installedAt: '2026-08-16T08:00:00.000Z' });
|
||||
expect(installed.course).not.toHaveProperty('version');
|
||||
await expect(library.listInstalled()).resolves.toEqual([installed]);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://square.example/api/learning/courses/course-1/download', expect.objectContaining({
|
||||
headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('removes partial files when the archive digest does not match', async () => {
|
||||
const expected = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1]);
|
||||
const tampered = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 2]);
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(course(expected)), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(tampered, { status: 200 }));
|
||||
const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
|
||||
await expect(library.download('course-1')).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' });
|
||||
await expect(library.listInstalled()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not send the Works Square token to an object-storage redirect', async () => {
|
||||
const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 9]);
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { Location: 'https://objects.example/course.zip' } }))
|
||||
.mockResolvedValueOnce(new Response(archive, { status: 200 }));
|
||||
const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
|
||||
await library.download('course-1');
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(3, new URL('https://objects.example/course.zip'), {
|
||||
headers: { Accept: 'application/zip' },
|
||||
redirect: 'follow',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the frozen classroom payload from an installed package', async () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('classroom.json', Buffer.from(JSON.stringify({
|
||||
stage: { id: 'stage-local', name: 'Python 基础入门' },
|
||||
scenes: Array.from({ length: 10 }, (_, index) => ({ id: `scene-${index}`, order: index, type: 'slide' })),
|
||||
})));
|
||||
const archive = zip.toBuffer();
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(archive, { status: 200 }));
|
||||
const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const installed = await library.download('course-1');
|
||||
expect(await readFile(installed.archivePath)).toEqual(archive);
|
||||
expect(JSON.parse(new AdmZip(installed.archivePath).readAsText('classroom.json')).stage.id).toBe('stage-local');
|
||||
|
||||
await expect(library.readClassroom('course-1')).resolves.toMatchObject({
|
||||
courseId: 'course-1',
|
||||
courseContentHash: 'a'.repeat(64),
|
||||
contentHash: 'a'.repeat(64),
|
||||
moduleId: null,
|
||||
moduleContentHash: 'a'.repeat(64),
|
||||
modules: [{ moduleId: 'main', title: 'Python 基础入门', sceneCount: 10 }],
|
||||
classroom: { stage: { id: 'stage-local' }, scenes: expect.any(Array) },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts large-course metadata and expands a selected frozen module for the production Stage', async () => {
|
||||
const moduleContentHash = 'b'.repeat(64);
|
||||
const moduleId = 'python-intro';
|
||||
const moduleRoot = `modules/${moduleId}/`;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('course.json', Buffer.from(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
modules: [{
|
||||
moduleId,
|
||||
title: 'Python 入门模块',
|
||||
summary: '变量、表达式与第一次练习',
|
||||
sceneCount: 1,
|
||||
contentHash: moduleContentHash,
|
||||
path: `modules/${moduleId}`,
|
||||
}],
|
||||
})));
|
||||
zip.addFile(`${moduleRoot}manifest.json`, Buffer.from(JSON.stringify({
|
||||
formatVersion: 1,
|
||||
exportedAt: '2026-08-16T00:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
stage: {
|
||||
name: 'Python 入门模块',
|
||||
language: 'zh-CN',
|
||||
interactiveMode: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
agents: [{
|
||||
name: '麦洛老师',
|
||||
role: 'teacher',
|
||||
persona: '循序渐进地讲解',
|
||||
avatar: '/avatars/teacher.png',
|
||||
color: '#3b82f6',
|
||||
priority: 0,
|
||||
}],
|
||||
scenes: [{
|
||||
type: 'slide',
|
||||
title: '变量是什么',
|
||||
order: 0,
|
||||
content: { type: 'slide', imageId: 'diagram' },
|
||||
actions: [{ type: 'speech', audioRef: 'audio/line.mp3', text: '变量用于保存数据。' }],
|
||||
}],
|
||||
mediaIndex: {
|
||||
'audio/line.mp3': { type: 'audio', mimeType: 'audio/mpeg' },
|
||||
'media/diagram.png': { type: 'generated', mimeType: 'image/png' },
|
||||
},
|
||||
})));
|
||||
zip.addFile(`${moduleRoot}bundle.json`, Buffer.from(JSON.stringify({
|
||||
meta: {
|
||||
coursewareId: moduleId,
|
||||
version: 1,
|
||||
stageName: 'Python 入门模块',
|
||||
sceneCount: 1,
|
||||
contentHash: moduleContentHash,
|
||||
},
|
||||
completeness: { complete: true },
|
||||
})));
|
||||
zip.addFile(`${moduleRoot}quiz/quiz.json`, Buffer.from(JSON.stringify({ quizzes: [] })));
|
||||
zip.addFile(`${moduleRoot}knowledge/knowledge.json`, Buffer.from(JSON.stringify({ documents: [] })));
|
||||
zip.addFile(`${moduleRoot}audio/line.mp3`, Buffer.from('audio-bytes'));
|
||||
zip.addFile(`${moduleRoot}media/diagram.png`, Buffer.from('image-bytes'));
|
||||
const archive = zip.toBuffer();
|
||||
const metadata = course(archive, {
|
||||
sceneCount: 1,
|
||||
capabilities: { modular: true, orderedModules: true, productionStagePerModule: true },
|
||||
modules: [{
|
||||
moduleId,
|
||||
title: 'Python 入门模块',
|
||||
summary: '变量、表达式与第一次练习',
|
||||
sceneCount: 1,
|
||||
contentHash: moduleContentHash,
|
||||
}],
|
||||
});
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(archive, { status: 200 }));
|
||||
const library = createLearningCourseLibrary({
|
||||
rootDirectory: root!,
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
|
||||
await library.download('course-1');
|
||||
const payload = await library.readClassroom('course-1', moduleId);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
courseId: 'course-1',
|
||||
courseContentHash: 'a'.repeat(64),
|
||||
contentHash: 'a'.repeat(64),
|
||||
moduleId,
|
||||
moduleContentHash,
|
||||
modules: [{ moduleId, title: 'Python 入门模块', sceneCount: 1, contentHash: moduleContentHash }],
|
||||
classroom: {
|
||||
stage: {
|
||||
id: `learning_course-1_${moduleId}`,
|
||||
name: 'Python 入门模块',
|
||||
interactiveMode: true,
|
||||
},
|
||||
scenes: [{ title: '变量是什么', order: 0 }],
|
||||
},
|
||||
});
|
||||
expect(payload.classroom.scenes[0]).toMatchObject({
|
||||
content: {
|
||||
imageId: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}media/diagram.png`,
|
||||
},
|
||||
actions: [{
|
||||
type: 'speech',
|
||||
audioUrl: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`,
|
||||
}],
|
||||
});
|
||||
|
||||
// The same verified aggregate package registered by the consumer must be
|
||||
// able to serve the rewritten immutable media URL to the production Stage.
|
||||
const artifactRoot = join(root!, 'player-artifact');
|
||||
const html = '<!doctype html><main>player</main>';
|
||||
await mkdir(join(artifactRoot, '_next', 'static'), { recursive: true });
|
||||
await mkdir(join(artifactRoot, 'avatars'), { recursive: true });
|
||||
await mkdir(join(artifactRoot, 'public'), { recursive: true });
|
||||
await writeFile(join(artifactRoot, 'index.html'), html);
|
||||
await writeFile(join(artifactRoot, 'artifact.json'), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
entrypoint: 'index.html',
|
||||
htmlSha256: createHash('sha256').update(html).digest('hex'),
|
||||
}));
|
||||
await writeFile(join(artifactRoot, '_next', 'static', 'app.js'), 'window.player = true');
|
||||
await writeFile(join(artifactRoot, 'avatars', 'teacher.png'), 'avatar');
|
||||
await writeFile(join(artifactRoot, 'public', 'openmaic-mark.png'), 'mark');
|
||||
const player = await createLearningPlayerServer({ artifactRoot });
|
||||
try {
|
||||
const response = await fetch(new URL(
|
||||
`/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`,
|
||||
player.url,
|
||||
));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('audio-bytes');
|
||||
} finally {
|
||||
await player.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user