265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { Action } from '@openmaic/dsl';
|
|
import {
|
|
compileVideoTimeline,
|
|
prepareQuizQuestionList,
|
|
type CompilerScene,
|
|
type QuizLayoutProbe,
|
|
} from '@/lib/video-export';
|
|
import { NO_ASSETS, stubProbe } from './helpers';
|
|
|
|
const speech = (id: string): Action =>
|
|
({ id, type: 'speech', text: `Narration for ${id}` }) as Action;
|
|
|
|
function scene(questions: readonly unknown[], order = 0): CompilerScene {
|
|
return {
|
|
id: `quiz-${order}`,
|
|
stageId: 'stage',
|
|
title: `Quiz ${order}`,
|
|
order,
|
|
type: 'quiz',
|
|
content: { type: 'quiz', questions },
|
|
actions: [speech(`speech-${order}`)],
|
|
} as CompilerScene;
|
|
}
|
|
|
|
const measured: QuizLayoutProbe = {
|
|
measureQuestionList: () => ({
|
|
contentHeightPx: 1001,
|
|
viewportHeightPx: 401,
|
|
frameHeightPx: 720,
|
|
}),
|
|
};
|
|
|
|
describe('Quiz question-list preparation', () => {
|
|
it('whitelists only learner-visible fields across all three question types', () => {
|
|
const questions = prepareQuizQuestionList(
|
|
scene([
|
|
{
|
|
id: 'single',
|
|
type: 'single',
|
|
question: 'Choose <one>',
|
|
options: [
|
|
{ value: 'A', label: 'Alpha' },
|
|
{ value: 'B', label: 'Beta', correct: true },
|
|
],
|
|
answer: ['B'],
|
|
analysis: 'SECRET_ANALYSIS',
|
|
points: 99,
|
|
attempt: { answer: 'B' },
|
|
},
|
|
{
|
|
id: 'multiple',
|
|
type: 'multiple',
|
|
question: 'Choose several',
|
|
options: ['Legacy option', { value: 'Z', label: 'Last' }],
|
|
submission: ['SECRET_SUBMISSION'],
|
|
},
|
|
{
|
|
id: 'short',
|
|
type: 'short_answer',
|
|
question: 'Explain $x^2$',
|
|
options: [{ value: 'LEAK', label: 'SECRET_OPTION' }],
|
|
commentPrompt: 'SECRET_RUBRIC',
|
|
review: 'SECRET_REVIEW',
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(questions).toEqual([
|
|
{
|
|
id: 'single',
|
|
type: 'single',
|
|
question: 'Choose <one>',
|
|
options: [
|
|
{ value: 'A', label: 'Alpha' },
|
|
{ value: 'B', label: 'Beta' },
|
|
],
|
|
},
|
|
{
|
|
id: 'multiple',
|
|
type: 'multiple',
|
|
question: 'Choose several',
|
|
options: [
|
|
{ value: 'A', label: 'Legacy option' },
|
|
{ value: 'Z', label: 'Last' },
|
|
],
|
|
},
|
|
{ id: 'short', type: 'short_answer', question: 'Explain $x^2$' },
|
|
]);
|
|
expect(JSON.stringify(questions)).not.toMatch(
|
|
/SECRET_(ANALYSIS|SUBMISSION|OPTION|RUBRIC|REVIEW)/,
|
|
);
|
|
});
|
|
|
|
it('skips malformed questions and safely normalizes damaged option data', () => {
|
|
const questions = prepareQuizQuestionList(
|
|
scene([
|
|
null,
|
|
{ id: '', type: 'single', question: 'missing id' },
|
|
{ id: 'bad-type', type: 'essay', question: 'unsupported' },
|
|
{ id: 'bad-question', type: 'single', question: 42 },
|
|
{
|
|
id: 'ok',
|
|
type: 'single',
|
|
question: 'Still renderable',
|
|
options: [null, 7, { value: 'A' }, { label: 'No value' }, ' Visible '],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(questions).toMatchObject([
|
|
{
|
|
id: 'ok',
|
|
type: 'single',
|
|
question: 'Still renderable',
|
|
options: [{ label: 'Visible' }],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Quiz question-list timeline planning', () => {
|
|
it('adds a measured list, preserves narration, and extends the Quiz deterministically', () => {
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Measured Quiz' },
|
|
scenes: [scene([{ id: 'q', type: 'single', question: 'Question' }])],
|
|
},
|
|
{
|
|
timing: stubProbe({ 'speech-0': 2400 }),
|
|
assets: NO_ASSETS,
|
|
quizLayout: measured,
|
|
},
|
|
);
|
|
|
|
expect(ir.totalDurationMs).toBe(11_650);
|
|
expect(ir.scenes[0]).toMatchObject({
|
|
startMs: 0,
|
|
durationMs: 11_650,
|
|
narration: [{ startMs: 0, durationMs: 2400 }],
|
|
visuals: [
|
|
{ kind: 'quiz-cover', startMs: 0, durationMs: 3000 },
|
|
{
|
|
kind: 'quiz-question-list',
|
|
startMs: 2400,
|
|
durationMs: 9250,
|
|
contentHeightPx: 1001,
|
|
viewportHeightPx: 401,
|
|
scrollDistancePx: 600,
|
|
pixelsPerSecond: 96,
|
|
transitionDurationMs: 600,
|
|
topHoldDurationMs: 1200,
|
|
scrollDurationMs: 6250,
|
|
bottomHoldDurationMs: 1200,
|
|
},
|
|
],
|
|
});
|
|
expect(ir.diagnostics).toContainEqual(
|
|
expect.objectContaining({
|
|
code: 'cover-card',
|
|
sceneId: 'quiz-0',
|
|
message: expect.stringContaining('cover-to-question-list sequence'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('uses a static 2.4s hold when the measured list fits the viewport', () => {
|
|
const fitProbe: QuizLayoutProbe = {
|
|
measureQuestionList: () => ({
|
|
contentHeightPx: 400,
|
|
viewportHeightPx: 400,
|
|
frameHeightPx: 1080,
|
|
}),
|
|
};
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Fit Quiz' },
|
|
scenes: [scene([{ id: 'q', type: 'short_answer', question: 'Question' }])],
|
|
},
|
|
{ timing: stubProbe({ 'speech-0': 2400 }), assets: NO_ASSETS, quizLayout: fitProbe },
|
|
);
|
|
const list = ir.scenes[0].visuals[1];
|
|
|
|
expect(list).toMatchObject({
|
|
kind: 'quiz-question-list',
|
|
scrollDistancePx: 0,
|
|
pixelsPerSecond: 0,
|
|
scrollDurationMs: 0,
|
|
durationMs: 3000,
|
|
});
|
|
expect(ir.totalDurationMs).toBe(5400);
|
|
});
|
|
|
|
it('scales the 96px/s baseline with frame height at an equivalent visual duration', () => {
|
|
const compileAt = (frameHeightPx: number, scrollDistancePx: number) => {
|
|
const viewportHeightPx = 400;
|
|
const ir = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: `${frameHeightPx}p Quiz` },
|
|
scenes: [scene([{ id: 'q', type: 'single', question: 'Question' }])],
|
|
},
|
|
{
|
|
timing: stubProbe({ 'speech-0': 1000 }),
|
|
assets: NO_ASSETS,
|
|
quizLayout: {
|
|
measureQuestionList: () => ({
|
|
contentHeightPx: viewportHeightPx + scrollDistancePx,
|
|
viewportHeightPx,
|
|
frameHeightPx,
|
|
}),
|
|
},
|
|
},
|
|
);
|
|
return ir.scenes[0].visuals[1];
|
|
};
|
|
|
|
expect(compileAt(720, 960)).toMatchObject({
|
|
pixelsPerSecond: 96,
|
|
scrollDurationMs: 10_000,
|
|
});
|
|
expect(compileAt(1080, 1440)).toMatchObject({
|
|
pixelsPerSecond: 144,
|
|
scrollDurationMs: 10_000,
|
|
});
|
|
});
|
|
|
|
it('keeps empty, missing-probe, and failed measurements on the existing cover-only path', () => {
|
|
const failed: QuizLayoutProbe = { measureQuestionList: () => null };
|
|
const empty = compileVideoTimeline(
|
|
{ stage: { id: 'stage', name: 'Empty' }, scenes: [scene([])] },
|
|
{ timing: stubProbe({ 'speech-0': 2400 }), assets: NO_ASSETS, quizLayout: measured },
|
|
);
|
|
const missing = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Missing' },
|
|
scenes: [scene([{ id: 'q', type: 'single', question: 'Q' }])],
|
|
},
|
|
{ timing: stubProbe({ 'speech-0': 2400 }), assets: NO_ASSETS },
|
|
);
|
|
const unavailable = compileVideoTimeline(
|
|
{
|
|
stage: { id: 'stage', name: 'Failed' },
|
|
scenes: [scene([{ id: 'q', type: 'single', question: 'Q' }])],
|
|
},
|
|
{ timing: stubProbe({ 'speech-0': 2400 }), assets: NO_ASSETS, quizLayout: failed },
|
|
);
|
|
|
|
expect(empty.totalDurationMs).toBe(2400);
|
|
expect(empty.scenes[0].visuals).toHaveLength(1);
|
|
expect(missing.totalDurationMs).toBe(2400);
|
|
expect(missing.scenes[0].visuals).toHaveLength(1);
|
|
expect(unavailable.totalDurationMs).toBe(2400);
|
|
expect(unavailable.scenes[0].visuals).toHaveLength(1);
|
|
expect(empty.diagnostics).not.toContainEqual(
|
|
expect.objectContaining({ code: 'quiz-layout-unavailable' }),
|
|
);
|
|
expect(missing.diagnostics).toContainEqual(
|
|
expect.objectContaining({ severity: 'warn', code: 'quiz-layout-unavailable' }),
|
|
);
|
|
expect(unavailable.diagnostics).toContainEqual(
|
|
expect.objectContaining({ severity: 'warn', code: 'quiz-layout-unavailable' }),
|
|
);
|
|
});
|
|
});
|