246 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|