Files
openmaic/OpenMAIC/tests/courseware/server-classroom-publish.test.ts
2026-08-16 14:58:47 +08:00

273 lines
9.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import type { PPTImageElement, Slide, Whiteboard } from '@openmaic/dsl';
import { computeBundleContentHash, readFrozenBundleDocuments } from '@/lib/bundle/packager';
import {
createFileBundleByteStore,
type BundleByteStore,
} from '@/lib/courseware-repo/bundle-store';
import { createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
import { publishPersistedClassroom } from '@/lib/server/classroom-courseware-publish';
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage';
const BASE_URL = 'http://localhost:3000';
const CLASSROOM_ID = 'persisted-classroom';
const COURSEWARE_ID = 'large-course-module-01';
const MEDIA_FILENAME = 'cell.png';
const AUDIO_ID = 'narration-01';
const MEDIA_URL = `${BASE_URL}/api/classroom-media/${CLASSROOM_ID}/media/${MEDIA_FILENAME}`;
const AUDIO_URL = `${BASE_URL}/api/classroom-media/${CLASSROOM_ID}/audio/${AUDIO_ID}.mp3`;
const PUBLISHED_AT = '2026-08-15T08:00:00.000Z';
let tempRoot: string;
let classroomsDir: string;
let records: CoursewareRepo;
let bytes: BundleByteStore;
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'server-classroom-publish-'));
classroomsDir = path.join(tempRoot, 'classrooms');
records = createFileCoursewareRepo(path.join(tempRoot, 'coursewares'));
bytes = createFileBundleByteStore(path.join(tempRoot, 'courseware-bundles'));
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
function imageElement(id: string, src: string): PPTImageElement {
return {
id,
type: 'image',
fixedRatio: true,
src,
left: 10,
top: 10,
width: 320,
height: 180,
rotate: 0,
};
}
function stageWhiteboard(src: string): Whiteboard {
return {
id: 'stage-whiteboard',
viewportSize: 1000,
viewportRatio: 16 / 9,
elements: [imageElement('stage-whiteboard-image', src)],
};
}
function slideCanvas(src: string): Slide {
return {
id: 'slide-canvas',
viewportSize: 1000,
viewportRatio: 16 / 9,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#2563eb'],
fontColor: '#111827',
fontName: 'Inter',
},
elements: [imageElement('scene-image', src)],
};
}
function teacherAgent(): GeneratedAgentConfig {
return {
id: 'teacher-agent',
name: '麦老师',
role: 'teacher',
persona: '耐心、清晰,会用类比帮助学生理解抽象概念。',
avatar: '/avatars/teacher.png',
color: '#2563eb',
priority: 10,
voiceConfig: { providerId: 'test-tts', voiceId: 'teacher-voice' },
voiceDesign: { identity: 'adult teacher', texture: 'warm and clear', delivery: 'measured' },
};
}
function persistedClassroom(options: { includeAudioUrl?: boolean } = {}): PersistedClassroomData {
const agent = teacherAgent();
const stage: Stage = {
id: CLASSROOM_ID,
name: '大课模块:细胞结构',
description: '运营端生成的连续课程模块。',
languageDirective: 'zh-CN',
style: 'light',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_001,
interactiveMode: true,
agentIds: [agent.id],
generatedAgentConfigs: [agent],
whiteboard: [stageWhiteboard(MEDIA_URL)],
};
const scenes: Scene[] = [
{
id: 'scene-slide',
stageId: CLASSROOM_ID,
type: 'slide',
title: '细胞示意图',
order: 0,
content: { type: 'slide', canvas: slideCanvas(MEDIA_URL) },
actions: [
{
id: 'speech-01',
type: 'speech',
text: '先观察细胞的基本结构。',
audioId: AUDIO_ID,
...(options.includeAudioUrl ? { audioUrl: AUDIO_URL } : {}),
voice: 'teacher-voice',
},
],
},
{
id: 'scene-interactive',
stageId: CLASSROOM_ID,
type: 'interactive',
title: '互动标注',
order: 1,
content: {
type: 'interactive',
html: '<main id="cell-lab"><button type="button">Show nucleus</button></main>',
},
},
];
return { id: CLASSROOM_ID, stage, scenes, createdAt: PUBLISHED_AT };
}
async function persistFixture(
classroom: PersistedClassroomData,
options: { audio?: boolean } = {},
) {
const classroomRoot = path.join(classroomsDir, CLASSROOM_ID);
await fs.mkdir(path.join(classroomRoot, 'media'), { recursive: true });
await fs.mkdir(path.join(classroomRoot, 'audio'), { recursive: true });
await fs.writeFile(path.join(classroomsDir, `${CLASSROOM_ID}.json`), JSON.stringify(classroom));
await fs.writeFile(path.join(classroomRoot, 'media', MEDIA_FILENAME), new Uint8Array([1, 2, 3]));
if (options.audio) {
await fs.writeFile(
path.join(classroomRoot, 'audio', `${AUDIO_ID}.mp3`),
new Uint8Array([4, 5, 6]),
);
}
}
describe('publishPersistedClassroom', () => {
test('freezes the persisted classroom into exact immutable versions', async () => {
const classroom = persistedClassroom({ includeAudioUrl: true });
await persistFixture(classroom, { audio: true });
const first = await publishPersistedClassroom(classroom, {
coursewareId: COURSEWARE_ID,
baseUrl: BASE_URL,
classroomsDir,
publishedAt: PUBLISHED_AT,
repos: { records, bytes },
});
expect(first.record.version).toBe(1);
expect(first.package.meta.version).toBe(1);
expect(first.documents.meta.version).toBe(1);
expect(first.record.contentHash).toBe(first.package.contentHash);
expect(first.documents.meta.contentHash).toBe(first.package.contentHash);
expect(first.package.contentHash).toMatch(/^[0-9a-f]{64}$/);
const stored = await bytes.read(COURSEWARE_ID, 1);
expect(stored).not.toBeNull();
expect(await computeBundleContentHash(stored!)).toBe(first.record.contentHash);
const documents = await readFrozenBundleDocuments(stored!);
expect(documents.meta).toMatchObject({
coursewareId: COURSEWARE_ID,
version: 1,
publishedAt: PUBLISHED_AT,
contentHash: first.record.contentHash,
});
expect(await records.getRecord(COURSEWARE_ID, 1)).toMatchObject({
version: 1,
contentHash: documents.meta.contentHash,
status: 'published',
});
expect(documents.manifest.agents).toEqual([
expect.objectContaining({
name: '麦老师',
role: 'teacher',
persona: '耐心、清晰,会用类比帮助学生理解抽象概念。',
voiceConfig: { providerId: 'test-tts', voiceId: 'teacher-voice' },
voiceDesign: {
identity: 'adult teacher',
texture: 'warm and clear',
delivery: 'measured',
},
}),
]);
const speech = documents.manifest.scenes[0].actions?.[0];
expect(speech).toMatchObject({ type: 'speech', audioRef: `audio/${AUDIO_ID}.mp3` });
expect(speech).not.toHaveProperty('audioId');
expect(speech).not.toHaveProperty('audioUrl');
expect(documents.manifest.mediaIndex[`audio/${AUDIO_ID}.mp3`]).toMatchObject({
type: 'audio',
format: 'mp3',
});
const mediaPath = Object.keys(documents.manifest.mediaIndex).find((entry) =>
entry.startsWith('media/frozen_'),
);
expect(mediaPath).toMatch(/^media\/frozen_[0-9a-f]{24}\.png$/);
const frozenMediaRef = mediaPath!.slice('media/'.length, -'.png'.length);
const sceneImage = documents.manifest.scenes[0].content;
expect(sceneImage.type).toBe('slide');
if (sceneImage.type === 'slide') {
expect(sceneImage.canvas.elements[0]).toMatchObject({ type: 'image', src: frozenMediaRef });
}
expect(documents.manifest.stage.whiteboard?.[0].elements[0]).toMatchObject({
type: 'image',
src: frozenMediaRef,
});
expect(JSON.stringify(documents.manifest)).not.toContain('/api/classroom-media/');
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(stored!);
expect(zip.file(`audio/${AUDIO_ID}.mp3`)).toBeDefined();
expect(zip.file(mediaPath!)).toBeDefined();
const second = await publishPersistedClassroom(classroom, {
coursewareId: COURSEWARE_ID,
baseUrl: BASE_URL,
classroomsDir,
publishedAt: PUBLISHED_AT,
repos: { records, bytes },
});
expect(second.record.version).toBe(2);
expect(second.package.meta.version).toBe(2);
expect(second.documents.meta.version).toBe(2);
expect(second.record.contentHash).toBe(second.package.contentHash);
expect((await records.getLatestRecord(COURSEWARE_ID))?.version).toBe(2);
});
test('fails closed when narration bytes are missing', async () => {
const classroom = persistedClassroom();
await persistFixture(classroom);
await expect(
publishPersistedClassroom(classroom, {
coursewareId: COURSEWARE_ID,
baseUrl: BASE_URL,
classroomsDir,
publishedAt: PUBLISHED_AT,
repos: { records, bytes },
}),
).rejects.toThrow(/incomplete.*audio.*narration/i);
expect(await records.getLatestRecord(COURSEWARE_ID)).toBeNull();
expect(await records.listRecords()).toEqual([]);
expect(await bytes.read(COURSEWARE_ID, 1)).toBeNull();
});
});