Files
openmaic/OpenMAIC/tests/video-export/quiz-reflow.test.ts
2026-08-16 14:58:47 +08:00

172 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Action } from '@openmaic/dsl';
import {
compileVideoTimeline,
reflowQuizTimelines,
type CompilerScene,
type QuizLayoutProbe,
type SubtitleCue,
type VideoTimelineScene,
} from '@/lib/video-export';
import { NO_ASSETS, stubProbe } from './helpers';
function timedScene(id: string, index: number, startMs: number, durationMs: number) {
return {
id,
index,
title: id,
type: index === 0 ? 'quiz' : 'slide',
startMs,
durationMs,
supported: true,
base: { kind: 'visual-segments' },
visuals: [
{
kind: 'quiz-cover',
startMs,
durationMs,
title: id,
questionCount: 1,
totalPoints: 1,
},
],
narration: [
{
actionIndex: 0,
startMs: startMs + 10,
durationMs: 100,
text: id,
audio: { durationMs: 100, source: 'stored', present: false },
},
],
effects: [
{
actionIndex: 1,
type: 'spotlight',
descriptorId: 'spotlight.v1',
startMs: startMs + 20,
durationMs: 100,
elementId: 'element',
geometry: null,
params: {},
degraded: true,
},
],
videos: [
{
actionIndex: 2,
startMs: startMs + 30,
durationMs: 100,
elementId: 'element',
geometry: null,
rotate: 0,
present: false,
degraded: true,
durationSource: 'skipped',
},
],
markers: [{ actionIndex: 3, kind: 'beat', startMs: startMs + 40, durationMs: 100 }],
} as VideoTimelineScene;
}
describe('pure Quiz absolute-time reflow', () => {
it('extends the owner and shifts every absolute field and subtitle in later scenes', () => {
const scenes = [timedScene('quiz', 0, 0, 1000), timedScene('slide', 1, 1000, 2000)];
const subtitles: SubtitleCue[] = [
{ index: 0, sceneId: 'quiz', startMs: 10, endMs: 110, text: 'quiz' },
{ index: 1, sceneId: 'slide', startMs: 1010, endMs: 1110, text: 'slide' },
];
const result = reflowQuizTimelines(scenes, subtitles, 3000, [3000, 0]);
expect(result.totalDurationMs).toBe(6000);
expect(result.scenes[0]).toMatchObject({
startMs: 0,
durationMs: 4000,
narration: [{ startMs: 10 }],
effects: [{ startMs: 20 }],
videos: [{ startMs: 30 }],
markers: [{ startMs: 40 }],
});
expect(result.scenes[1]).toMatchObject({
startMs: 4000,
durationMs: 2000,
visuals: [{ startMs: 4000 }],
narration: [{ startMs: 4010 }],
effects: [{ startMs: 4020 }],
videos: [{ startMs: 4030 }],
markers: [{ startMs: 4040 }],
});
expect(result.subtitles).toEqual([
{ index: 0, sceneId: 'quiz', startMs: 10, endMs: 110, text: 'quiz' },
{ index: 1, sceneId: 'slide', startMs: 4010, endMs: 4110, text: 'slide' },
]);
});
it('accumulates extensions from consecutive Quiz scenes without changing relative action timing', () => {
const scenes = [
timedScene('quiz-1', 0, 0, 1000),
timedScene('quiz-2', 1, 1000, 1000),
timedScene('slide', 2, 2000, 1000),
];
const result = reflowQuizTimelines(scenes, [], 3000, [3000, 7000, 0]);
expect(result.scenes.map((scene) => [scene.startMs, scene.durationMs])).toEqual([
[0, 4000],
[4000, 8000],
[12_000, 1000],
]);
expect(result.scenes[2].narration[0].startMs - result.scenes[2].startMs).toBe(10);
expect(result.totalDurationMs).toBe(13_000);
});
});
function quiz(id: string, order: number): CompilerScene {
return {
id,
stageId: 'stage',
title: id,
order,
type: 'quiz',
content: { type: 'quiz', questions: [{ id: `${id}-q`, type: 'single', question: 'Q' }] },
actions: [{ id: `${id}-speech`, type: 'speech', text: id } as Action],
} as CompilerScene;
}
describe('Quiz scroll-duration bounds and cumulative compiler reflow', () => {
it('clamps one-pixel overflow to 4s and extreme overflow to 24s', () => {
const probe: QuizLayoutProbe = {
measureQuestionList: (scene) =>
scene.id === 'min'
? { contentHeightPx: 401, viewportHeightPx: 400, frameHeightPx: 720 }
: { contentHeightPx: 100_000, viewportHeightPx: 400, frameHeightPx: 720 },
};
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Bounds' }, scenes: [quiz('min', 0), quiz('max', 1)] },
{
timing: stubProbe({ 'min-speech': 1000, 'max-speech': 1000 }),
assets: NO_ASSETS,
quizLayout: probe,
},
);
const lists = ir.scenes.map((scene) => scene.visuals[1]);
expect(lists[0]).toMatchObject({
scrollDistancePx: 1,
scrollDurationMs: 4000,
pixelsPerSecond: 0.25,
});
expect(lists[1]).toMatchObject({
scrollDistancePx: 99_600,
scrollDurationMs: 24_000,
pixelsPerSecond: 4150,
});
expect(ir.scenes.map((scene) => [scene.startMs, scene.durationMs])).toEqual([
[0, 8000],
[8000, 28_000],
]);
expect(ir.subtitles.map((cue) => cue.startMs)).toEqual([0, 8000]);
expect(ir.totalDurationMs).toBe(36_000);
});
});