356 lines
13 KiB
TypeScript
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>叶 绿体 & 场所</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);
|
|
});
|
|
});
|