import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; // ─── Shared mock control (hoisted so vi.mock factories can see it) ──────── const ctl = vi.hoisted(() => ({ frameworkJson: '{}', /** Deferred gate awaited inside the fake generateClassroom (cancel test). */ gate: Promise.resolve() as Promise, releaseGate: () => {}, failMarkers: [] as string[], requirements: [] as string[], interactiveModes: [] as boolean[], classrooms: {} as Record, frameworkGate: Promise.resolve() as Promise, frameworkAbortSignals: [] as AbortSignal[], })); vi.mock('@/lib/server/resolve-model', () => ({ resolveModel: async () => ({ model: {}, modelInfo: { outputWindow: 8000 }, modelString: 'test/model', providerId: 'test', apiKey: 'k', thinkingConfig: undefined, }), })); vi.mock('@/lib/ai/llm', () => ({ callLLM: async (params: { abortSignal?: AbortSignal }) => { if (params.abortSignal) ctl.frameworkAbortSignals.push(params.abortSignal); await ctl.frameworkGate; return { text: ctl.frameworkJson }; }, })); vi.mock('@/lib/ai/providers', () => ({ isProviderKeyRequired: () => false, })); vi.mock('@/lib/server/classroom-storage', async (importOriginal) => ({ ...(await importOriginal()), readClassroom: async (classroomId: string) => ctl.classrooms[classroomId] ?? null, })); vi.mock('@/lib/server/classroom-generation', () => ({ generateClassroom: async ( input: { requirement: string; interactiveMode?: boolean }, options: { signal?: AbortSignal }, ) => { await ctl.gate; if (options?.signal?.aborted) throw new DOMException('cancelled', 'AbortError'); ctl.requirements.push(input.requirement); ctl.interactiveModes.push(input.interactiveMode === true); if (ctl.failMarkers.some((marker) => input.requirement.includes(marker))) { throw new Error('simulated module failure'); } const hash = Buffer.from(input.requirement).toString('hex').slice(0, 8); const classroomId = `classroom-${hash}`; const moduleNumber = ctl.requirements.length; const classroom = { id: classroomId, stage: { id: `stage-${moduleNumber}`, name: `实际生成模块 ${moduleNumber}` }, scenes: [ { id: `scene-${moduleNumber}`, stageId: `stage-${moduleNumber}`, order: 1, title: `实际互动场景 ${moduleNumber}`, type: 'interactive', content: { type: 'interactive', widgetType: 'simulation', widgetConfig: { type: 'simulation', concept: `实际知识 ${moduleNumber}` }, html: `

实际覆盖知识 ${moduleNumber}

`, }, actions: [], }, ], createdAt: new Date().toISOString(), }; ctl.classrooms[classroomId] = classroom; return { ...classroom, url: `http://localhost/classroom/${hash}`, scenesCount: classroom.scenes.length, }; }, })); // ─── Environment ───────────────────────────────────────────── const FRAMEWORK = { courseTitle: '机器学习入门', languageDirective: '用中文教学。', targetAudience: '零基础成人学习者。', summary: '导论课程。', courseGoals: ['建立机器学习基础框架', '能够选择和评估基础模型'], continuityContract: { terminology: ['统一使用“特征”“标签”和“模型”'], teachingStyle: '案例引入、可视化解释、互动练习。', difficultyProgression: '由概念识别逐步递进到综合应用。', assessmentStrategy: '每模块形成性检查,末模块综合测验。', }, modules: [ { index: 1, title: '模块一', description: '基础概念。', learningObjectives: ['目标1'], generationPrompt: '用日常预测案例建立机器学习基础概念。', incomingKnowledge: [], outgoingKnowledge: ['基础概念'], excludedTopics: ['模型调优'], estimatedMinutes: 18, }, { index: 2, title: '模块二', description: '进阶内容。', learningObjectives: ['目标2'], generationPrompt: '承接基础概念,通过可视化解释模型训练。', prerequisites: '模块 1', incomingKnowledge: ['基础概念'], outgoingKnowledge: ['模型训练流程'], excludedTopics: ['综合评估'], estimatedMinutes: 22, }, { index: 3, title: '模块三', description: '综合实践。', learningObjectives: ['目标3'], generationPrompt: '用完整实践任务整合模型训练流程。', prerequisites: '模块 2', incomingKnowledge: ['模型训练流程'], outgoingKnowledge: ['完整实践能力'], excludedTopics: ['最终总结'], estimatedMinutes: 24, }, { index: 4, title: '模块四', description: '总结与测验。', learningObjectives: ['目标4'], generationPrompt: '组织综合测验并引导学习者反思迁移。', prerequisites: '模块 1–3', incomingKnowledge: ['完整实践能力'], outgoingKnowledge: ['迁移应用能力'], excludedTopics: [], estimatedMinutes: 20, }, ], }; /** Same framework, but module 2's brief carries the failure marker. */ const FRAMEWORK_WITH_FAILING_MODULE = { ...FRAMEWORK, modules: FRAMEWORK.modules.map((m) => m.index === 2 ? { ...m, description: '进阶内容 [FAIL]。' } : m, ), }; let frameworksDir: string; let jobsDir: string; let frozenCoursesDir: string; let frozenPackagesDir: string; beforeEach(async () => { frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-fw-')); jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-jobs-')); frozenCoursesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-courses-')); frozenPackagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-packages-')); vi.resetModules(); vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir); vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir); vi.stubEnv('LEARNING_COURSE_STORE_DIR', frozenCoursesDir); vi.stubEnv('LEARNING_COURSE_PACKAGE_DIR', frozenPackagesDir); ctl.frameworkJson = JSON.stringify(FRAMEWORK); ctl.gate = Promise.resolve(); ctl.releaseGate = () => {}; ctl.failMarkers = ['[FAIL]']; ctl.requirements = []; ctl.interactiveModes = []; ctl.classrooms = {}; ctl.frameworkGate = Promise.resolve(); ctl.frameworkAbortSignals = []; }); afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(frameworksDir, { recursive: true, force: true }); await fs.rm(jobsDir, { recursive: true, force: true }); await fs.rm(frozenCoursesDir, { recursive: true, force: true }); await fs.rm(frozenPackagesDir, { recursive: true, force: true }); }); /** Wait until a condition holds (poll every 10ms, fail after 3s). */ async function waitFor(cond: () => Promise | boolean, label: string) { const deadline = Date.now() + 3000; for (;;) { if (await cond()) return; if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`); await new Promise((r) => setTimeout(r, 10)); } } async function setupCourse(requirement = '大型课题需求') { const { createCourse } = await import('@/lib/course-framework/runner'); const { readCourseRecord } = await import('@/lib/course-framework/store'); await createCourse('course-1', { requirement }); return { readCourseRecord: () => readCourseRecord('course-1') }; } describe('course runner — two-phase pipeline', () => { test('creation runs ONLY the framework phase and waits for confirmation', async () => { const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); const record = await readCourseRecord(); expect(record?.status).toBe('framework_ready'); expect(record?.framework?.courseTitle).toBe('机器学习入门'); expect(record?.framework?.modules).toHaveLength(4); // NO module was generated before confirmation. expect(record?.modules.map((m) => m.status)).toEqual([ 'pending', 'pending', 'pending', 'pending', ]); expect(record?.modules.every((m) => !m.classroomId)).toBe(true); expect(record?.modules[0]?.generationPromptSnapshot).toContain('日常预测案例'); }); test('framework cancellation reaches the LLM signal and cannot later overwrite cancelled state', async () => { let releaseFramework!: () => void; ctl.frameworkGate = new Promise((resolve) => { releaseFramework = resolve; }); const { readCourseRecord } = await setupCourse(); const { cancelCourseGenerationJob, runCourseFrameworkGeneration, } = await import('@/lib/course-framework/runner'); const run = runCourseFrameworkGeneration('course-1', 'http://localhost'); await waitFor(() => ctl.frameworkAbortSignals.length === 1, 'framework LLM call'); expect(cancelCourseGenerationJob('course-1')).toBe(true); expect(ctl.frameworkAbortSignals[0]?.aborted).toBe(true); releaseFramework(); await run; const record = await readCourseRecord(); expect(record?.status).toBe('cancelled'); expect(record?.framework).toBeUndefined(); }); test('confirmation (module phase) generates every module in order; course completes', async () => { const { createCourse } = await import('@/lib/course-framework/runner'); const { readCourseRecord } = await import('@/lib/course-framework/store'); await createCourse( 'course-1', { requirement: '大型课题需求' }, { ownerPrincipalId: 'works-owner-1' }, ); const { runCourseFrameworkGeneration, runCourseModuleGeneration } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); const record = await readCourseRecord('course-1'); expect(record?.status).toBe('completed'); expect(record?.modules.map((m) => m.status)).toEqual([ 'succeeded', 'succeeded', 'succeeded', 'succeeded', ]); expect(record?.modules.every((m) => m.classroomId?.startsWith('classroom-'))).toBe(true); expect(record?.modules.every((m) => m.outputDigest?.sceneCount === 1)).toBe(true); expect(record?.modules[1]?.continuityInputRefs).toEqual([ { moduleIndex: 1, classroomId: record?.modules[0]?.classroomId, semanticHash: record?.modules[0]?.outputDigest?.semanticHash, }, ]); expect(record?.modules[3]?.continuityInputRefs).toHaveLength(3); expect(ctl.requirements).toHaveLength(4); expect(ctl.interactiveModes).toEqual([true, true, true, true]); expect(ctl.requirements[0]).toContain('教学语言与术语处理:用中文教学'); expect(ctl.requirements[0]).toContain('主 Agent 模块生成提示词:用日常预测案例'); expect(ctl.requirements[0]).toContain('约 18 分钟'); expect(ctl.requirements[1]).toContain('可假定的入门知识:基础概念'); expect(ctl.requirements[1]).toContain('前序模块的实际生成产出'); expect(ctl.requirements[1]).toContain('实际覆盖知识 1'); const { listClassroomGenerationJobs } = await import('@/lib/server/classroom-job-store'); const moduleJobs = await listClassroomGenerationJobs(10); expect(moduleJobs).toHaveLength(4); expect(moduleJobs.every((job) => job.ownerPrincipalId === 'works-owner-1')).toBe(true); }); test('enforces one active generation run per course', async () => { let release!: () => void; ctl.gate = new Promise((resolve) => { release = resolve; }); const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } = await import('@/lib/course-framework/runner'); const { readClassroomGenerationJob } = await import('@/lib/server/classroom-job-store'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); const fullRun = runCourseModuleGeneration('course-1', 'http://localhost'); await waitFor(async () => { const record = await readCourseRecord(); const moduleRec = record?.modules[0]; if (moduleRec?.status !== 'generating' || !moduleRec.jobId) return false; return (await readClassroomGenerationJob(moduleRec.jobId))?.status === 'running'; }, 'full course module run'); expect(() => runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }), ).toThrow('already has an active generation run'); release(); await fullRun; expect((await readCourseRecord())?.status).toBe('completed'); }); test('resumes legacy frameworks through read-time compatibility defaults', async () => { const { readCourseRecord } = await setupCourse('旧版大型课程需求'); const { updateCourseRecord } = await import('@/lib/course-framework/store'); const { runCourseModuleGeneration } = await import('@/lib/course-framework/runner'); const legacyModules = FRAMEWORK.modules.map((module) => ({ index: module.index, title: module.title, description: module.description, learningObjectives: module.learningObjectives, prerequisites: module.prerequisites, estimatedMinutes: module.estimatedMinutes, })); await updateCourseRecord('course-1', { framework: { courseTitle: FRAMEWORK.courseTitle, languageDirective: FRAMEWORK.languageDirective, summary: FRAMEWORK.summary, modules: legacyModules, } as never, modules: legacyModules.map((module) => ({ index: module.index, title: module.title, description: module.description, status: 'pending' as const, })), status: 'framework_ready', }); await runCourseModuleGeneration('course-1', 'http://localhost'); const record = await readCourseRecord(); expect(record?.status).toBe('completed'); expect(record?.framework?.courseGoals.length).toBeGreaterThan(0); expect(record?.modules[0]?.generationPromptSnapshot).toContain('基础概念'); expect(ctl.requirements[0]).toContain('教学语言与术语处理:用中文教学'); expect(ctl.requirements[0]).toContain('重点覆盖:基础概念'); }); test('a failed module pauses later modules to preserve continuity', async () => { ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE); const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); const record = await readCourseRecord(); expect(record?.status).toBe('failed'); expect(record?.modules.map((m) => m.status)).toEqual([ 'succeeded', 'failed', 'pending', 'pending', ]); expect(record?.modules[1]?.error).toContain('simulated module failure'); expect(ctl.requirements).toHaveLength(2); }); test('resume skips succeeded modules and finishes the failed one', async () => { ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE); const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); expect((await readCourseRecord())?.status).toBe('failed'); // The operator's fix: the module no longer fails. ctl.failMarkers = []; await runCourseModuleGeneration('course-1', 'http://localhost'); const record = await readCourseRecord(); expect(record?.status).toBe('completed'); expect(record?.modules.map((m) => m.status)).toEqual([ 'succeeded', 'succeeded', 'succeeded', 'succeeded', ]); }); test('regenerates a failed module and every pending downstream module in order', async () => { ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE); const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); expect((await readCourseRecord())?.status).toBe('failed'); ctl.failMarkers = []; await runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }); const record = await readCourseRecord(); expect(record?.status).toBe('completed'); expect(record?.modules[1]?.status).toBe('succeeded'); expect(ctl.requirements).toHaveLength(5); expect(record?.modules[3]?.continuityInputRefs).toHaveLength(3); }); test('regenerating a succeeded module immediately invalidates and rebuilds its tail', async () => { const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } = await import('@/lib/course-framework/runner'); const { updateCourseModule } = await import('@/lib/course-framework/store'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); const before = await readCourseRecord(); expect(before?.status).toBe('completed'); const upstreamClassroomId = before?.modules[0]?.classroomId; const oldDigests = before?.modules.slice(1).map((moduleRec) => moduleRec.outputDigest); await updateCourseModule('course-1', 3, { generationPromptSnapshot: 'stale prompt from an older attempt', }); let release!: () => void; ctl.gate = new Promise((resolve) => { release = resolve; }); const regeneration = runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2, }); await waitFor(async () => { const record = await readCourseRecord(); return record?.modules[1]?.status === 'generating'; }, 'module 2 regeneration cascade'); const during = await readCourseRecord(); expect(during?.status).toBe('generating'); expect(during?.modules[0]?.classroomId).toBe(upstreamClassroomId); expect(during?.modules[1]?.status).toBe('generating'); expect(during?.modules[1]).not.toHaveProperty('classroomId'); expect(during?.modules[1]).not.toHaveProperty('outputDigest'); expect(during?.modules.slice(2)).toEqual([ { index: 3, title: '模块三', description: '综合实践。', status: 'pending', generationPromptSnapshot: FRAMEWORK.modules[2].generationPrompt, }, { index: 4, title: '模块四', description: '总结与测验。', status: 'pending', generationPromptSnapshot: FRAMEWORK.modules[3].generationPrompt, }, ]); release(); await regeneration; const after = await readCourseRecord(); expect(after?.status).toBe('completed'); expect(after?.modules.map((moduleRec) => moduleRec.status)).toEqual([ 'succeeded', 'succeeded', 'succeeded', 'succeeded', ]); expect(ctl.requirements).toHaveLength(7); expect(after?.modules[1]?.outputDigest?.semanticHash).not.toBe(oldDigests?.[0]?.semanticHash); expect(after?.modules[2]?.outputDigest?.semanticHash).not.toBe(oldDigests?.[1]?.semanticHash); expect(after?.modules[2]?.continuityInputRefs?.[1]).toEqual({ moduleIndex: 2, classroomId: after?.modules[1]?.classroomId, semanticHash: after?.modules[1]?.outputDigest?.semanticHash, }); }); test('regenerating the framework resets modules back to pending', async () => { const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration, regenerateCourseFramework } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); expect((await readCourseRecord())?.status).toBe('completed'); await regenerateCourseFramework('course-1', 'http://localhost'); const record = await readCourseRecord(); expect(record?.status).toBe('framework_ready'); expect(record?.modules).toHaveLength(4); expect(record?.modules.every((m) => m.status === 'pending' && !m.classroomId)).toBe(true); }); test('rejects every source generation entry after the course id is frozen', async () => { const { readCourseRecord } = await setupCourse(); const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob, regenerateCourseFramework, } = await import('@/lib/course-framework/runner'); await runCourseFrameworkGeneration('course-1', 'http://localhost'); await runCourseModuleGeneration('course-1', 'http://localhost'); const before = await readCourseRecord(); expect(before?.status).toBe('completed'); const { persistFrozenLearningPackage } = await import('@/lib/makelore-course/package'); await persistFrozenLearningPackage({ schemaVersion: 2, courseId: 'course-1', mode: 'large', sourceJobId: 'course-1', sourceDigest: 'c'.repeat(64), contentHash: 'a'.repeat(64), archiveSha256: 'b'.repeat(64), archiveBytes: 4, formatVersion: 2, minPlayerVersion: '2.0.0', title: '机器学习入门', sceneCount: 4, moduleCount: 4, capabilities: { modular: true }, createdAt: '2026-08-16T00:00:00.000Z', }, new Uint8Array([0x50, 0x4b, 0x03, 0x04])); const mutations = [ () => runCourseFrameworkGeneration('course-1', 'http://localhost'), () => runCourseModuleGeneration('course-1', 'http://localhost'), () => runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }), () => regenerateCourseFramework('course-1', 'http://localhost'), ]; for (const mutate of mutations) { await expect(mutate()).rejects.toMatchObject({ name: 'FrozenLearningCourseMutationError', }); } expect(await readCourseRecord()).toEqual(before); }); test('cancel aborts the current module and keeps the course cancellable', async () => { let release!: () => void; ctl.gate = new Promise((r) => { release = r; }); ctl.releaseGate = release; const { createCourse, runCourseFrameworkGeneration, runCourseModuleGeneration, cancelCourseGenerationJob, } = await import('@/lib/course-framework/runner'); const { readCourseRecord, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); const { readClassroomGenerationJob } = await import('@/lib/server/classroom-job-store'); await createCourse('course-1', { requirement: '大型课题需求' }); await runCourseFrameworkGeneration('course-1', 'http://localhost'); const runPromise = runCourseModuleGeneration('course-1', 'http://localhost'); // Wait until module 1's classroom job is running. await waitFor(async () => { const record = await readCourseRecord('course-1'); const moduleRec = record?.modules[0]; if (!record?.framework || moduleRec?.status !== 'generating' || !moduleRec.jobId) return false; const job = await readClassroomGenerationJob(moduleRec.jobId); return job?.status === 'running'; }, 'module 1 classroom job running'); cancelCourseGenerationJob('course-1'); release(); await runPromise; const record = await readCourseRecordReconciled('course-1'); expect(record?.status).toBe('cancelled'); // The in-flight module returned to pending; nothing succeeded yet. expect(record?.modules.every((m) => m.status === 'pending')).toBe(true); }); });