155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { compileVideoTimeline, VideoTimelineSchema } from '@/lib/video-export';
|
|
import { NO_ASSETS, NO_PROBE, interactive, speech, stubInteractiveHtml } from './helpers';
|
|
import { quizScrollScene } from './quiz-scroll-fixture';
|
|
|
|
describe('compileVideoTimeline — static interactive HTML', () => {
|
|
it('promotes prepared HTML to a supported base and plans its asset', () => {
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Interactive' },
|
|
scenes: [interactive('widget', '<!doctype html><h1>Widget</h1>')],
|
|
},
|
|
{
|
|
timing: NO_PROBE,
|
|
assets: NO_ASSETS,
|
|
interactive: stubInteractiveHtml({
|
|
widget: {
|
|
id: 'interactive:widget',
|
|
present: true,
|
|
contentHash: 'a'.repeat(64),
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(() => VideoTimelineSchema.parse(ir)).not.toThrow();
|
|
expect(ir.scenes[0]).toMatchObject({
|
|
supported: true,
|
|
base: {
|
|
kind: 'interactive-html',
|
|
assetId: 'interactive:widget',
|
|
assetRef: 'interactive/001-widget.html',
|
|
contentHash: 'a'.repeat(64),
|
|
readyTimeoutMs: 8000,
|
|
settleMs: 250,
|
|
},
|
|
});
|
|
expect(ir.assets.entries).toContainEqual({
|
|
assetId: 'interactive:widget',
|
|
kind: 'html',
|
|
path: 'interactive/001-widget.html',
|
|
present: true,
|
|
});
|
|
expect(ir.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: 'interactive-static-html', sceneId: 'widget' }),
|
|
);
|
|
expect(ir.diagnostics).not.toContainEqual(
|
|
expect.objectContaining({ code: 'unsupported-scene', sceneId: 'widget' }),
|
|
);
|
|
});
|
|
|
|
it('uses the existing placeholder for missing embedded HTML', () => {
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Interactive' },
|
|
scenes: [interactive('missing', '')],
|
|
},
|
|
{ timing: NO_PROBE, assets: NO_ASSETS },
|
|
);
|
|
|
|
expect(ir.scenes[0]).toMatchObject({
|
|
supported: false,
|
|
base: { kind: 'placeholder', reason: expect.stringContaining('missing') },
|
|
});
|
|
expect(ir.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: 'missing-interactive-html', sceneId: 'missing' }),
|
|
);
|
|
});
|
|
|
|
it('surfaces unresolved resources and falls back without failing compile', () => {
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Interactive' },
|
|
scenes: [interactive('remote', '<img src="https://example.test/a.png">')],
|
|
},
|
|
{
|
|
timing: NO_PROBE,
|
|
assets: NO_ASSETS,
|
|
interactive: stubInteractiveHtml({
|
|
remote: {
|
|
id: 'interactive:remote',
|
|
present: false,
|
|
failure: 'unresolved-resource',
|
|
message: 'Interactive HTML has unresolved resources: https://example.test/a.png.',
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
expect(ir.scenes[0].base).toMatchObject({
|
|
kind: 'placeholder',
|
|
reason: expect.stringContaining('unresolved resources'),
|
|
});
|
|
expect(ir.diagnostics).toContainEqual(
|
|
expect.objectContaining({ code: 'unresolved-interactive-resource', sceneId: 'remote' }),
|
|
);
|
|
});
|
|
|
|
it('keeps static interactive first-class while a preceding Quiz shifts all later timing', () => {
|
|
const quiz = quizScrollScene(0);
|
|
const widget = interactive(
|
|
'widget-after-quiz',
|
|
'<!doctype html><h1>Widget after Quiz</h1>',
|
|
[speech('widget-narration', 'The interactive scene follows the extended Quiz.')],
|
|
1,
|
|
);
|
|
const interactiveSource = stubInteractiveHtml({
|
|
'widget-after-quiz': {
|
|
id: 'interactive:widget-after-quiz',
|
|
present: true,
|
|
contentHash: 'b'.repeat(64),
|
|
},
|
|
});
|
|
const baseline = compileVideoTimeline(
|
|
{ stage: { id: 'stage', name: 'Integrated export' }, scenes: [quiz, widget] },
|
|
{ timing: NO_PROBE, assets: NO_ASSETS, interactive: interactiveSource },
|
|
);
|
|
const ir = compileVideoTimeline(
|
|
{ stage: { id: 'stage', name: 'Integrated export' }, scenes: [quiz, widget] },
|
|
{
|
|
timing: NO_PROBE,
|
|
assets: NO_ASSETS,
|
|
interactive: interactiveSource,
|
|
quizLayout: {
|
|
measureQuestionList: () => ({
|
|
contentHeightPx: 1_200,
|
|
viewportHeightPx: 480,
|
|
frameHeightPx: 720,
|
|
}),
|
|
},
|
|
},
|
|
);
|
|
const list = ir.scenes[0].visuals.find((visual) => visual.kind === 'quiz-question-list');
|
|
if (!list || list.kind !== 'quiz-question-list') throw new Error('Quiz list not planned');
|
|
|
|
expect(ir.version).toBe(4);
|
|
expect(() => VideoTimelineSchema.parse(ir)).not.toThrow();
|
|
expect(ir.scenes[1]).toMatchObject({
|
|
startMs: baseline.scenes[1].startMs + list.durationMs,
|
|
supported: true,
|
|
base: {
|
|
kind: 'interactive-html',
|
|
assetRef: 'interactive/002-widget-after-quiz.html',
|
|
},
|
|
});
|
|
expect(ir.scenes[1].narration[0].startMs).toBe(
|
|
baseline.scenes[1].narration[0].startMs + list.durationMs,
|
|
);
|
|
expect(ir.subtitles.find((cue) => cue.sceneId === widget.id)?.startMs).toBe(
|
|
baseline.subtitles.find((cue) => cue.sceneId === widget.id)!.startMs + list.durationMs,
|
|
);
|
|
expect(ir.totalDurationMs).toBe(baseline.totalDurationMs + list.durationMs);
|
|
});
|
|
});
|