feat: productionize learning engine and classroom
This commit is contained in:
266
OpenMAIC/tests/lib/makelore-course/aggregate.test.ts
Normal file
266
OpenMAIC/tests/lib/makelore-course/aggregate.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CourseRecord } from '@/lib/course-framework/types';
|
||||
import type { Scene, Stage } from '@/lib/types/stage';
|
||||
|
||||
const teacher = {
|
||||
id: 'teacher-1',
|
||||
name: '麦洛老师',
|
||||
role: 'teacher',
|
||||
persona: '耐心讲解',
|
||||
avatar: '/avatars/teacher.png',
|
||||
color: '#3b82f6',
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
function classroom(classroomId: string, title: string, text: string) {
|
||||
const stage = {
|
||||
id: classroomId,
|
||||
name: title,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
generatedAgentConfigs: [teacher],
|
||||
} as Stage;
|
||||
const scenes = [
|
||||
{
|
||||
id: `${classroomId}-scene`,
|
||||
stageId: classroomId,
|
||||
title,
|
||||
order: 1,
|
||||
type: 'slide',
|
||||
content: {
|
||||
type: 'slide',
|
||||
canvas: {
|
||||
id: `${classroomId}-canvas`,
|
||||
viewportSize: 100,
|
||||
viewportRatio: 1.6,
|
||||
theme: { color: '#fff', backgroundColor: '#000', fontName: 'sans-serif' },
|
||||
elements: [
|
||||
{
|
||||
id: `${classroomId}-text`,
|
||||
type: 'text',
|
||||
content: `<p>${text}</p>`,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 20,
|
||||
rotate: 0,
|
||||
defaultFontName: 'sans-serif',
|
||||
defaultColor: '#000',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: `${classroomId}-speech`,
|
||||
type: 'speech',
|
||||
text,
|
||||
audioId: `${classroomId}-missing-audio`,
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
] as unknown as Scene[];
|
||||
return { id: classroomId, stage, scenes };
|
||||
}
|
||||
|
||||
function largeCourseRecord(): CourseRecord {
|
||||
return {
|
||||
id: 'large-1',
|
||||
status: 'completed',
|
||||
requirement: '学习 Python',
|
||||
enableTTS: false,
|
||||
interactiveMode: false,
|
||||
framework: {
|
||||
courseTitle: 'Python 系统课',
|
||||
languageDirective: 'zh-CN',
|
||||
targetAudience: '零基础学习者',
|
||||
summary: '从变量到循环',
|
||||
courseGoals: [],
|
||||
continuityContract: {
|
||||
terminology: [],
|
||||
teachingStyle: '渐进',
|
||||
difficultyProgression: '由浅入深',
|
||||
assessmentStrategy: '练习',
|
||||
},
|
||||
modules: [],
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
index: 1,
|
||||
title: '变量',
|
||||
description: '变量基础',
|
||||
status: 'succeeded',
|
||||
classroomId: 'classroom-1',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: '循环',
|
||||
description: '循环基础',
|
||||
status: 'succeeded',
|
||||
classroomId: 'classroom-2',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-08-16T00:00:00.000Z',
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
} as CourseRecord;
|
||||
}
|
||||
|
||||
describe.sequential('expanded aggregate learning package', () => {
|
||||
let root = '';
|
||||
const env = {
|
||||
data: process.env.LEARNING_DATA_DIR,
|
||||
classrooms: process.env.CLASSROOM_DATA_DIR,
|
||||
packages: process.env.LEARNING_COURSE_PACKAGE_DIR,
|
||||
courses: process.env.LEARNING_COURSE_STORE_DIR,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'learning-aggregate-'));
|
||||
process.env.LEARNING_DATA_DIR = root;
|
||||
process.env.CLASSROOM_DATA_DIR = join(root, 'classrooms');
|
||||
process.env.LEARNING_COURSE_PACKAGE_DIR = join(root, 'packages');
|
||||
process.env.LEARNING_COURSE_STORE_DIR = join(root, 'courses');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const key = {
|
||||
data: 'LEARNING_DATA_DIR',
|
||||
classrooms: 'CLASSROOM_DATA_DIR',
|
||||
packages: 'LEARNING_COURSE_PACKAGE_DIR',
|
||||
courses: 'LEARNING_COURSE_STORE_DIR',
|
||||
}[name]!;
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('writes course.json plus expanded stable module roots and resolves module knowledge', async () => {
|
||||
const { persistClassroom } = await import('@/lib/server/classroom-storage');
|
||||
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
|
||||
await persistClassroom(
|
||||
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
|
||||
'http://engine',
|
||||
);
|
||||
|
||||
const course = largeCourseRecord();
|
||||
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
|
||||
const frozen = await finalizeLargeLearningCourse({
|
||||
jobId: 'large-1',
|
||||
course,
|
||||
baseUrl: 'http://engine',
|
||||
});
|
||||
const { readUniqueMakeloreCoursePackage } = await import('@/lib/makelore-course/package');
|
||||
const bytes = await readUniqueMakeloreCoursePackage('large-1');
|
||||
expect(bytes).not.toBeNull();
|
||||
const zip = await JSZip.loadAsync(bytes!);
|
||||
expect(zip.file('course.json')).not.toBeNull();
|
||||
expect(zip.file('aggregate.json')).toBeNull();
|
||||
expect(Object.keys(zip.files).some((name) => name.endsWith('/courseware.zip'))).toBe(false);
|
||||
for (const moduleId of ['large-1_m1', 'large-1_m2']) {
|
||||
expect(zip.file(`modules/${moduleId}/manifest.json`)).not.toBeNull();
|
||||
expect(zip.file(`modules/${moduleId}/bundle.json`)).not.toBeNull();
|
||||
expect(zip.file(`modules/${moduleId}/knowledge/knowledge.json`)).not.toBeNull();
|
||||
expect(zip.file(`modules/${moduleId}/quiz/quiz.json`)).not.toBeNull();
|
||||
}
|
||||
const courseJson = JSON.parse(await zip.file('course.json')!.async('string')) as {
|
||||
contentHash: string;
|
||||
modules: Array<{ moduleId: string; path: string }>;
|
||||
};
|
||||
expect(courseJson.contentHash).toBe(frozen.contentHash);
|
||||
expect(courseJson.modules).toEqual([
|
||||
expect.objectContaining({ moduleId: 'large-1_m1', path: 'modules/large-1_m1/' }),
|
||||
expect.objectContaining({ moduleId: 'large-1_m2', path: 'modules/large-1_m2/' }),
|
||||
]);
|
||||
|
||||
const { loadMakeloreCourse, resolveMakeloreCourseModule, courseKnowledge } =
|
||||
await import('@/lib/makelore-runtime/course-store');
|
||||
const loaded = await loadMakeloreCourse('large-1', frozen.contentHash);
|
||||
expect(loaded).toMatchObject({ mode: 'large', moduleCount: 2 });
|
||||
const secondHash = (frozen.courseStructure!.modules as Array<{ contentHash: string }>)[1]!
|
||||
.contentHash;
|
||||
const selected = await resolveMakeloreCourseModule(loaded!, {
|
||||
moduleId: 'large-1_m2',
|
||||
moduleContentHash: secondHash,
|
||||
});
|
||||
expect(selected).not.toBeNull();
|
||||
expect(courseKnowledge(selected!).knowledge.scenes[0]!.text).toContain('循环用于重复执行代码');
|
||||
});
|
||||
|
||||
it('reuses only the exact frozen source digest and never overwrites an immutable course id', async () => {
|
||||
const { persistClassroom } = await import('@/lib/server/classroom-storage');
|
||||
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
|
||||
await persistClassroom(
|
||||
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
|
||||
'http://engine',
|
||||
);
|
||||
const course = largeCourseRecord();
|
||||
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
|
||||
|
||||
const first = await finalizeLargeLearningCourse({
|
||||
jobId: 'large-1',
|
||||
course,
|
||||
baseUrl: 'http://engine',
|
||||
});
|
||||
expect(first.sourceDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
const firstBytes = await readFile(join(root, 'packages', 'large-1.zip'));
|
||||
|
||||
const reused = await finalizeLargeLearningCourse({
|
||||
jobId: 'large-1',
|
||||
course,
|
||||
baseUrl: 'http://engine',
|
||||
});
|
||||
expect(reused).toEqual(first);
|
||||
|
||||
await persistClassroom(
|
||||
classroom('classroom-2', '模块二', '循环与条件分支共同控制程序流程'),
|
||||
'http://engine',
|
||||
);
|
||||
await expect(finalizeLargeLearningCourse({
|
||||
jobId: 'large-1',
|
||||
course,
|
||||
baseUrl: 'http://engine',
|
||||
})).rejects.toThrow('已冻结');
|
||||
await expect(readFile(join(root, 'packages', 'large-1.zip'))).resolves.toEqual(firstBytes);
|
||||
|
||||
await expect(
|
||||
finalizeLargeLearningCourse({
|
||||
jobId: 'large-1',
|
||||
course: { ...course, status: 'generating' },
|
||||
baseUrl: 'http://engine',
|
||||
}),
|
||||
).rejects.toThrow('Large course is not complete');
|
||||
});
|
||||
|
||||
it('never treats a legacy frozen record without a source digest as rebuildable', async () => {
|
||||
const { persistClassroom } = await import('@/lib/server/classroom-storage');
|
||||
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
|
||||
await persistClassroom(
|
||||
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
|
||||
'http://engine',
|
||||
);
|
||||
const course = largeCourseRecord();
|
||||
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
|
||||
await finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' });
|
||||
const packagePath = join(root, 'packages', 'large-1.zip');
|
||||
const recordPath = join(root, 'courses', 'large-1.json');
|
||||
const frozenBytes = await readFile(packagePath);
|
||||
const record = JSON.parse(await readFile(recordPath, 'utf8')) as Record<string, unknown>;
|
||||
delete record.sourceDigest;
|
||||
await writeFile(recordPath, JSON.stringify(record));
|
||||
|
||||
await expect(
|
||||
finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' }),
|
||||
).rejects.toThrow('已冻结');
|
||||
await expect(readFile(packagePath)).resolves.toEqual(frozenBytes);
|
||||
});
|
||||
});
|
||||
54
OpenMAIC/tests/lib/makelore-course/package.test.ts
Normal file
54
OpenMAIC/tests/lib/makelore-course/package.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import JSZip from 'jszip';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
packageUniqueMakeloreCourse,
|
||||
persistUniqueMakeloreCourse,
|
||||
readUniqueMakeloreCoursePackage,
|
||||
} from '@/lib/makelore-course/package';
|
||||
import { loadMakeloreCourse } from '@/lib/makelore-runtime/course-store';
|
||||
import { validateCoursePackageDescriptor } from '../../../packages/@makelore/learning-contracts/src/index';
|
||||
import type { Scene, Stage } from '@/lib/types/stage';
|
||||
|
||||
describe('unique Makelore course package', () => {
|
||||
let root = '';
|
||||
const previousPackageRoot = process.env.MAKELORE_COURSE_PACKAGE_DIR;
|
||||
const previousStoreRoot = process.env.MAKELORE_COURSE_STORE_DIR;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
if (previousPackageRoot === undefined) delete process.env.MAKELORE_COURSE_PACKAGE_DIR;
|
||||
else process.env.MAKELORE_COURSE_PACKAGE_DIR = previousPackageRoot;
|
||||
if (previousStoreRoot === undefined) delete process.env.MAKELORE_COURSE_STORE_DIR;
|
||||
else process.env.MAKELORE_COURSE_STORE_DIR = previousStoreRoot;
|
||||
});
|
||||
|
||||
it('freezes classroom, quiz and knowledge without a product version', async () => {
|
||||
const stage = {
|
||||
id: 'stage-1', name: '变量入门', createdAt: Date.now(), updatedAt: Date.now(),
|
||||
generatedAgentConfigs: [{ id: 'teacher-1', name: 'AI Teacher', role: 'teacher', persona: '耐心讲解', avatar: '/avatars/teacher.png', color: '#3b82f6', priority: 10 }],
|
||||
} as Stage;
|
||||
const scenes = [{
|
||||
id: 'scene-1', stageId: stage.id, title: '变量是什么', order: 1, type: 'interactive',
|
||||
content: { type: 'interactive', html: '<!doctype html><html><head></head><body><button>试一试</button><script>document.querySelector("button").onclick=()=>document.body.dataset.clicked="1"</script></body></html>' },
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
}] as Scene[];
|
||||
const course = await packageUniqueMakeloreCourse({ courseId: 'course-1', stage, scenes });
|
||||
const zip = await JSZip.loadAsync(course.bytes);
|
||||
const descriptor = JSON.parse(await zip.file('descriptor.json')!.async('string'));
|
||||
expect(descriptor).toMatchObject({ courseId: 'course-1', formatVersion: 1, sceneCount: 1 });
|
||||
expect(validateCoursePackageDescriptor(descriptor)).toMatchObject({ ok: true, errors: [] });
|
||||
expect(descriptor).not.toHaveProperty('version');
|
||||
expect(zip.file('classroom.json')).not.toBeNull();
|
||||
expect(zip.file('knowledge/knowledge.json')).not.toBeNull();
|
||||
|
||||
root = await mkdtemp(join(tmpdir(), 'makelore-course-package-'));
|
||||
process.env.MAKELORE_COURSE_PACKAGE_DIR = join(root, 'packages');
|
||||
process.env.MAKELORE_COURSE_STORE_DIR = join(root, 'courses');
|
||||
await persistUniqueMakeloreCourse('course-1', course);
|
||||
await expect(readUniqueMakeloreCoursePackage('course-1')).resolves.toEqual(course.bytes);
|
||||
await expect(loadMakeloreCourse('course-1', course.contentHash)).resolves.toMatchObject({ title: '变量入门' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user