Files
openmaic/OpenMAIC/tests/bundle/packager.test.ts

356 lines
13 KiB
TypeScript

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('<h1>光合作用</h1><p>叶&nbsp;绿体 &amp; 场所</p>')).toBe(
'光合作用\n叶 绿体 & 场所',
);
});
test('removes script and style blocks', () => {
expect(
htmlToPlainText('<div>内容</div><script>window.x = 1;</script><style>.a{}</style>'),
).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<ReturnType<typeof packageCourseware>>) {
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: '<main>Lab</main><script src="https://cdn.example.test/lab.js"></script>',
},
} 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: '<main>Python lab</main><script>const py = await loadPyodide();</script>',
},
} 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);
});
});