Files
openmaic/OpenMAIC/tests/course-framework/publish-validation.test.ts
2026-08-16 14:58:47 +08:00

246 lines
7.8 KiB
TypeScript

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<string, PersistedClassroomData>(),
}));
vi.mock('@/lib/server/classroom-storage', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/server/classroom-storage')>()),
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 = `<button>模块 ${moduleIndex} 互动练习</button>`,
): 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<CoursePublishValidationError>);
}
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: '<button>已修改的互动内容</button>' }
: 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,
'<button>同一可见内容</button><script>window.answer = 1;</script>',
);
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,
'<button>同一可见内容</button><script>window.answer = 2;</script>',
);
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);
});
});