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', '

Widget

')], }, { 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', '')], }, { 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', '

Widget after Quiz

', [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); }); });