feat: integrate learning module
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

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

View 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();
}
});
});