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: `

${text}

`, 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; 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); }); });