chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,264 @@
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user