import { describe, test, expect } from 'vitest'; import { packageCourseware, readFrozenBundleDocuments } from '@/lib/bundle/packager'; import { extractKnowledgePack, extractQuizPack, htmlToPlainText } from '@/lib/bundle/extract'; import { FROZEN_BUNDLE_CONTENT_HASH_VERSION, FROZEN_BUNDLE_FORMAT_VERSION, FROZEN_BUNDLE_KIND, FROZEN_BUNDLE_KNOWLEDGE_FILE, FROZEN_BUNDLE_MANIFEST_FILE, FROZEN_BUNDLE_QUIZ_FILE, } from '@/lib/bundle/types'; import { AUDIO_BYTES, makeScenes, makeStage, mediaRecord } from './fixtures'; import type { Scene } from '@/lib/types/stage'; // ─── htmlToPlainText ───────────────────────────────────────── describe('htmlToPlainText', () => { test('strips tags, decodes entities, collapses whitespace', () => { expect(htmlToPlainText('

光合作用

叶 绿体 & 场所

')).toBe( '光合作用\n叶 绿体 & 场所', ); }); test('removes script and style blocks', () => { expect( htmlToPlainText('
内容
'), ).toBe('内容'); }); }); // ─── extractKnowledgePack / extractQuizPack ────────────────── describe('extractKnowledgePack', () => { test('extracts text and narration per scene', () => { const pack = extractKnowledgePack('cw-1', makeStage(), makeScenes()); expect(pack.coursewareId).toBe('cw-1'); expect(pack.language).toBe('zh-CN'); expect(pack.scenes).toHaveLength(3); const slide = pack.scenes.find((s) => s.sceneId === 'sc-1')!; expect(slide.text).toContain('光合作用'); expect(slide.text).toContain('叶绿体是光合作用的场所'); expect(slide.narration).toContain('今天我们来学习光合作用'); const quiz = pack.scenes.find((s) => s.sceneId === 'sc-2')!; expect(quiz.text).toContain('问:光合作用发生在细胞的哪个结构'); expect(quiz.text).toContain('解析:叶绿体是光合作用的场所'); const interactive = pack.scenes.find((s) => s.sceneId === 'sc-3')!; expect(interactive.text).toContain('虚拟光合实验'); expect(interactive.text).not.toContain('secret'); }); }); describe('extractQuizPack', () => { test('collects quiz questions per scene', () => { const pack = extractQuizPack(makeScenes()); expect(pack.scenes).toHaveLength(1); expect(pack.scenes[0].sceneId).toBe('sc-2'); expect(pack.scenes[0].questions).toHaveLength(2); expect(pack.scenes[0].questions[0].answer).toEqual(['B']); }); }); // ─── packageCourseware ─────────────────────────────────────── const ZIP_FILES = { manifest: 'manifest.json', bundle: FROZEN_BUNDLE_MANIFEST_FILE, quiz: FROZEN_BUNDLE_QUIZ_FILE, knowledge: FROZEN_BUNDLE_KNOWLEDGE_FILE, }; async function loadZipEntries(result: Awaited>) { const JSZip = (await import('jszip')).default; const zip = await JSZip.loadAsync(await result.zip.arrayBuffer()); return zip; } describe('packageCourseware (happy path)', () => { test('produces a complete frozen bundle', async () => { const result = await packageCourseware({ coursewareId: 'cw-1', version: 2, stage: makeStage(), scenes: makeScenes(), mediaRecords: [ mediaRecord('cw-1:img-1', 'image', 'image/png'), mediaRecord('cw-1:vid-1', 'video', 'video/mp4', true), ], resolveAudioBytes: async (audioId) => (audioId === 'aud-1' ? AUDIO_BYTES : null), resolveMediaBytes: async (record) => record.id.includes('img') ? new Blob([new Uint8Array([7, 7, 7])], { type: 'image/png' }) : new Blob([new Uint8Array([8, 8, 8])], { type: 'video/mp4' }), publishedAt: '2026-08-15T00:00:00.000Z', appVersion: 'test', }); // Completeness expect(result.completeness.complete).toBe(true); expect(result.completeness.missing).toEqual([]); expect(result.completeness.audioCount).toBe(1); expect(result.completeness.mediaCount).toBe(2); expect(result.completeness.interactiveScenes).toBe(1); // Meta expect(result.meta).toMatchObject({ kind: FROZEN_BUNDLE_KIND, formatVersion: FROZEN_BUNDLE_FORMAT_VERSION, coursewareId: 'cw-1', version: 2, stageName: '示例课件:光合作用', language: 'zh-CN', sceneCount: 3, contentHashVersion: FROZEN_BUNDLE_CONTENT_HASH_VERSION, knowledgeVersion: 1, }); expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/); expect(result.meta.contentHash).toBe(result.contentHash); // ZIP entries const zip = await loadZipEntries(result); for (const name of Object.values(ZIP_FILES)) expect(zip.file(name)).toBeDefined(); expect(zip.file('audio/aud-1.mp3')).toBeDefined(); expect(zip.file('media/img-1.png')).toBeDefined(); expect(zip.file('media/vid-1.mp4')).toBeDefined(); expect(zip.file('media/vid-1.poster.jpg')).toBeDefined(); // Manifest serialization: audioId → audioRef, agents positional expect(result.manifest.stage.name).toBe('示例课件:光合作用'); expect(result.manifest.agents).toHaveLength(1); expect(result.manifest.agents[0].name).toBe('苏老师'); expect(result.manifest.scenes).toHaveLength(3); const speech = result.manifest.scenes[0].actions![0]; expect(speech).toMatchObject({ type: 'speech', audioRef: 'audio/aud-1.mp3' }); expect(speech).not.toHaveProperty('audioId'); expect(result.manifest.mediaIndex['audio/aud-1.mp3']).toMatchObject({ type: 'audio' }); expect(result.manifest.mediaIndex['media/img-1.png']).toMatchObject({ type: 'generated' }); expect(result.manifest.mediaIndex['media/vid-1.mp4']).toMatchObject({ type: 'generated' }); expect(result.manifest.mediaIndex['audio/aud-1.mp3'].missing).toBeUndefined(); // Round-trip documents const documents = await readFrozenBundleDocuments(result.zip); expect(documents.meta.contentHash).toBe(result.contentHash); expect(documents.quiz.scenes).toHaveLength(1); expect(documents.knowledge.scenes).toHaveLength(3); }); test('content hash is deterministic for identical inputs', async () => { const common = { coursewareId: 'cw-1', version: 1, stage: makeStage(), scenes: makeScenes(), mediaRecords: [mediaRecord('cw-1:img-1', 'image', 'image/png')], resolveAudioBytes: async (audioId: string) => (audioId === 'aud-1' ? AUDIO_BYTES : null), resolveMediaBytes: async () => new Blob([new Uint8Array([7, 7, 7])], { type: 'image/png' }), publishedAt: '2026-08-15T00:00:00.000Z', appVersion: 'test', }; const first = await packageCourseware(common); const second = await packageCourseware(common); expect(second.contentHash).toBe(first.contentHash); }); test('content hash ignores export time while keeping the manifest timestamp', async () => { const common = { coursewareId: 'cw-stable-retry', version: 1, stage: makeStage(), scenes: makeScenes(), resolveAudioBytes: async () => AUDIO_BYTES, appVersion: 'test', }; const first = await packageCourseware({ ...common, publishedAt: '2026-08-15T00:00:00.000Z', }); const retry = await packageCourseware({ ...common, publishedAt: '2026-08-16T00:00:00.000Z', }); expect(retry.manifest.exportedAt).not.toBe(first.manifest.exportedAt); expect(retry.contentHash).toBe(first.contentHash); }); test('prepareScenes hook is applied', async () => { const result = await packageCourseware({ coursewareId: 'cw-1', version: 1, stage: makeStage(), scenes: makeScenes(), prepareScenes: async (_stageId, scenes) => scenes.map((s) => ({ ...s, title: `[prepared] ${s.title}` }) as Scene), resolveAudioBytes: async () => AUDIO_BYTES, publishedAt: '2026-08-15T00:00:00.000Z', }); expect(result.manifest.scenes[0].title).toContain('[prepared]'); }); }); describe('packageCourseware (missing resources)', () => { test('allows intentional silent narration when TTS was disabled', async () => { const scenes = structuredClone(makeScenes()); const speech = scenes[0].actions?.find((action) => action.type === 'speech'); if (speech?.type === 'speech') speech.audioUrl = 'https://should-not-survive.example/audio.mp3'; const result = await packageCourseware({ coursewareId: 'cw-silent', version: 1, stage: makeStage(), scenes, requireNarrationAudio: false, requireComplete: true, publishedAt: '2026-08-15T00:00:00.000Z', }); expect(result.completeness.complete).toBe(true); expect(result.completeness.audioCount).toBe(0); expect(result.completeness.missing).not.toContainEqual( expect.objectContaining({ kind: 'audio' }), ); expect(result.meta.runtimeCapabilities?.packaged.audio).toBe(false); expect(result.manifest.mediaIndex['audio/aud-1.mp3']).toBeUndefined(); expect(result.manifest.scenes[0].actions?.[0]).not.toHaveProperty('audioRef'); expect(result.manifest.scenes[0].actions?.[0]).not.toHaveProperty('audioUrl'); }); test('reports missing audio and marks the media index entry', async () => { const result = await packageCourseware({ coursewareId: 'cw-1', version: 1, stage: makeStage(), scenes: makeScenes(), resolveAudioBytes: async () => null, publishedAt: '2026-08-15T00:00:00.000Z', }); expect(result.completeness.complete).toBe(false); expect(result.completeness.missing).toContainEqual( expect.objectContaining({ kind: 'audio', ref: 'aud-1' }), ); expect(result.manifest.mediaIndex['audio/aud-1.mp3']).toMatchObject({ missing: true }); }); test('reports missing media and failed inline assets', async () => { const result = await packageCourseware({ coursewareId: 'cw-1', version: 1, stage: makeStage(), scenes: makeScenes(), mediaRecords: [mediaRecord('cw-1:img-1', 'image', 'image/png')], resolveAudioBytes: async () => AUDIO_BYTES, resolveMediaBytes: async () => null, publishedAt: '2026-08-15T00:00:00.000Z', }); expect(result.completeness.complete).toBe(false); expect(result.completeness.missing).toContainEqual( expect.objectContaining({ kind: 'media', ref: 'img-1' }), ); }); test('requireComplete turns gaps into hard errors', async () => { await expect( packageCourseware({ coursewareId: 'cw-1', version: 1, stage: makeStage(), scenes: makeScenes(), resolveAudioBytes: async () => null, requireComplete: true, publishedAt: '2026-08-15T00:00:00.000Z', }), ).rejects.toThrow(/incomplete/); }); }); describe('packageCourseware (strict frozen publish contract)', () => { test('rejects a bundle without a portable agent roster', async () => { const stage = makeStage(); stage.generatedAgentConfigs = []; await expect( packageCourseware({ coursewareId: 'cw-strict-roster', version: 1, stage, scenes: makeScenes(), agentConfigs: [], resolveAudioBytes: async () => AUDIO_BYTES, publishedAt: '2026-08-15T00:00:00.000Z', requireAgentRoster: true, requireInteractiveHtml: true, strictInteractiveAssets: true, requireComplete: true, }), ).rejects.toThrow(/no portable agent roster/i); }); test('rejects interactive HTML whose remote asset cannot be frozen', async () => { const scenes = makeScenes().map((scene) => scene.content.type === 'interactive' ? ({ ...scene, content: { ...scene.content, html: '
Lab
', }, } as Scene) : scene, ); await expect( packageCourseware({ coursewareId: 'cw-strict-interactive', version: 1, stage: makeStage(), scenes, resolveAudioBytes: async () => AUDIO_BYTES, fetchImpl: async () => new Response('missing', { status: 404 }), publishedAt: '2026-08-15T00:00:00.000Z', requireAgentRoster: true, requireInteractiveHtml: true, strictInteractiveAssets: true, requireComplete: true, }), ).rejects.toThrow(/incomplete.*interactive.*lab\.js/i); }); test('rejects an inlined Pyodide loader whose wasm and stdlib remain runtime downloads', async () => { const scenes = makeScenes().map((scene) => scene.content.type === 'interactive' ? ({ ...scene, content: { ...scene.content, html: '
Python lab
', }, } as Scene) : scene, ); await expect(packageCourseware({ coursewareId: 'cw-offline-python', version: 1, stage: makeStage(), scenes, resolveAudioBytes: async () => AUDIO_BYTES, publishedAt: '2026-08-16T00:00:00.000Z', requireAgentRoster: true, requireInteractiveHtml: true, strictInteractiveAssets: true, requireComplete: true, })).rejects.toThrow(/incomplete.*loadPyodide/i); }); });