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: '
', }, }, ]; 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(); }); });