import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { PersistedClassroomData } from '@/lib/server/classroom-storage'; import type { CourseFramework, CourseRecord } from '@/lib/course-framework/types'; import { buildCourseModuleOutputDigest } from '@/lib/course-framework/module-digest'; import { CoursePublishValidationError, validateCoursePublishSnapshot, } from '@/lib/course-framework/publish-validation'; const classroomState = vi.hoisted(() => ({ records: new Map(), })); vi.mock('@/lib/server/classroom-storage', async (importOriginal) => ({ ...(await importOriginal()), readClassroom: async (classroomId: string) => classroomState.records.get(classroomId) ?? null, })); const FRAMEWORK: CourseFramework = { courseTitle: '连续课程', languageDirective: '使用中文教学。', targetAudience: '零基础学习者。', summary: '从基础概念逐步进入实践。', courseGoals: ['掌握基础概念', '能够完成综合实践'], continuityContract: { terminology: ['统一使用“核心概念”'], teachingStyle: '先讲解再互动。', difficultyProgression: '从识别到应用。', assessmentStrategy: '每个模块完成一次形成性检查。', }, modules: [ { index: 1, title: '模块一', description: '建立基础。', learningObjectives: ['理解基础概念'], generationPrompt: '用互动实验建立基础概念。', incomingKnowledge: [], outgoingKnowledge: ['基础概念'], excludedTopics: ['综合实践'], estimatedMinutes: 15, }, { index: 2, title: '模块二', description: '承接基础并完成实践。', learningObjectives: ['应用基础概念'], generationPrompt: '承接前序实际产出完成综合实践。', prerequisites: '模块一', incomingKnowledge: ['基础概念'], outgoingKnowledge: ['综合实践能力'], excludedTopics: [], estimatedMinutes: 20, }, ], }; function classroom( id: string, moduleIndex: number, html = ``, ): PersistedClassroomData { const stageId = `stage-${moduleIndex}`; return { id, createdAt: `2026-08-15T0${moduleIndex}:00:00.000Z`, stage: { id: stageId, name: `实际模块 ${moduleIndex}`, createdAt: 1_700_000_000_000 + moduleIndex, updatedAt: 1_700_000_000_100 + moduleIndex, interactiveMode: true, }, scenes: [ { id: `scene-${moduleIndex}`, stageId, order: 1, title: `互动场景 ${moduleIndex}`, type: 'interactive', content: { type: 'interactive', widgetType: 'simulation', widgetConfig: { type: 'simulation', concept: `实际知识 ${moduleIndex}` }, html, }, actions: [], }, ] as never, }; } function validCourseRecord( first: PersistedClassroomData, second: PersistedClassroomData, ): CourseRecord { const firstDigest = buildCourseModuleOutputDigest(first); const secondDigest = buildCourseModuleOutputDigest(second); return { id: 'course-publish-validation', status: 'completed', requirement: '生成一门两模块连续课程', framework: FRAMEWORK, modules: [ { index: 1, title: '模块一', description: '建立基础。', status: 'succeeded', classroomId: first.id, outputDigest: firstDigest, continuityInputRefs: [], }, { index: 2, title: '模块二', description: '承接基础并完成实践。', status: 'succeeded', classroomId: second.id, outputDigest: secondDigest, continuityInputRefs: [ { moduleIndex: 1, classroomId: first.id, semanticHash: firstDigest.semanticHash, }, ], }, ], createdAt: '2026-08-15T00:00:00.000Z', updatedAt: '2026-08-15T03:00:00.000Z', }; } function seedValidCourse() { const first = classroom('classroom-module-1', 1); const second = classroom('classroom-module-2', 2); classroomState.records.set(first.id, first); classroomState.records.set(second.id, second); return { first, second, record: validCourseRecord(first, second) }; } async function expectValidationError(record: CourseRecord, code: string, moduleIndex: number) { await expect(validateCoursePublishSnapshot(record)).rejects.toMatchObject({ name: 'CoursePublishValidationError', code, moduleIndex, } satisfies Partial); } describe('course publish validation', () => { beforeEach(() => { classroomState.records.clear(); }); test('accepts two generated modules with exact actual-output continuity refs', async () => { const { record } = seedValidCourse(); const snapshot = await validateCoursePublishSnapshot(record); expect(snapshot.courseId).toBe(record.id); expect(snapshot.modules).toHaveLength(2); expect(snapshot.modules.map((entry) => entry.outputDigest.semanticHash)).toEqual( record.modules.map((moduleRecord) => moduleRecord.outputDigest?.semanticHash), ); }); test('rejects when a classroom actual output changed after its digest was persisted', async () => { const { first, record } = seedValidCourse(); classroomState.records.set(first.id, { ...first, scenes: first.scenes.map((scene) => ({ ...scene, title: '生成后被修改的场景', content: scene.content.type === 'interactive' ? { ...scene.content, html: '' } : scene.content, })) as never, }); await expectValidationError(record, 'MODULE_OUTPUT_CHANGED', 1); }); test('rejects script-only interactive drift that the bounded semantic digest omits', async () => { const first = classroom( 'classroom-module-1', 1, '', ); const second = classroom('classroom-module-2', 2); classroomState.records.set(first.id, first); classroomState.records.set(second.id, second); const record = validCourseRecord(first, second); const changed = classroom( first.id, 1, '', ); expect(buildCourseModuleOutputDigest(changed).semanticHash).toBe( record.modules[0].outputDigest?.semanticHash, ); expect(buildCourseModuleOutputDigest(changed).sourceRevisionHash).not.toBe( record.modules[0].outputDigest?.sourceRevisionHash, ); classroomState.records.set(first.id, changed); await expectValidationError(record, 'MODULE_OUTPUT_CHANGED', 1); }); test.each([ { label: 'stale semantic hash', refs: [ { moduleIndex: 1, classroomId: 'classroom-module-1', semanticHash: 'sha256:stale', }, ], code: 'CONTINUITY_STALE' as const, }, { label: 'missing refs', refs: undefined, code: 'CONTINUITY_UNVERIFIED' as const, }, ])('rejects downstream continuity with $label', async ({ refs, code }) => { const { record } = seedValidCourse(); record.modules[1] = { ...record.modules[1], continuityInputRefs: refs }; await expectValidationError(record, code, 2); }); test('rejects a module without non-empty interactive HTML', async () => { const first = classroom('classroom-module-1', 1, ' '); const second = classroom('classroom-module-2', 2); classroomState.records.set(first.id, first); classroomState.records.set(second.id, second); const record = validCourseRecord(first, second); await expectValidationError(record, 'INTERACTIVE_HTML_MISSING', 1); }); });