273 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|