import { describe, it, expect } from 'vitest';
import { spotlightV1 } from '@/lib/choreography';
import { compileVideoTimeline, emitHyperframes } from '@/lib/video-export';
import type { AssetMeta, CompilerScene } from '@/lib/video-export';
import {
NO_ASSETS,
NO_PROBE,
interactive,
slide,
quiz,
el,
speech,
spotlight,
laser,
playVideo,
stubProbe,
stubAssets,
stubInteractiveHtml,
} from './helpers';
const audioMeta = (id: string): AssetMeta => ({
id,
format: 'mp3',
present: true,
durationMs: 2000,
});
const videoMeta = (id: string): AssetMeta => ({ id, format: 'mp4', present: true });
function compileSample() {
const scenes = [
slide(
'intro',
[
speech('sp1', 'Welcome to the lesson', { audioId: 'a1' }),
spotlight('l1', 'e1'),
laser('la1', 'e1', '#00ff88'),
playVideo('v1', 'e1'),
],
{ title: 'Intro', elements: [el('e1', { left: 250, top: 140, width: 500, height: 280 })] },
),
quiz('checkpoint', [], 1),
];
return compileVideoTimeline(
{ stage: { id: 'stage', name: 'Sample Lesson' }, scenes },
{
timing: stubProbe({ sp1: 2000 }, { v1: 3000 }),
assets: stubAssets({ sp1: audioMeta('a1') }, { e1: videoMeta('stage:e1') }),
},
);
}
describe('emitHyperframes', () => {
const ir = compileSample();
const project = emitHyperframes(ir, { width: 1920, height: 1080 });
const html = project.files.find((f) => f.path === 'index.html')!.content;
it('emits the self-contained project file set', () => {
const paths = project.files.map((f) => f.path).sort();
expect(paths).toEqual(
[
'LICENSES/Inter-OFL-1.1.txt',
'README.md',
'index.html',
'openmaic-video-manifest.json',
'subtitles.srt',
'subtitles.vtt',
].sort(),
);
expect(
project.files.find((file) => file.path === 'LICENSES/Inter-OFL-1.1.txt')!.content,
).toContain('SIL OPEN FONT LICENSE Version 1.1');
});
it('builds one composition driven by one paused GSAP timeline', () => {
expect(html).toContain('data-composition-id="openmaic"');
expect(html).toContain('data-width="1920"');
expect(html).toContain('data-height="1080"');
expect(html).toContain(`data-duration="${ir.totalDurationMs / 1000}"`);
expect(html).toContain('gsap.timeline({ paused: true })');
expect(html).toContain('window.__timelines["openmaic"] = tl;');
});
it('lays out base / narration / video clips with clip attributes', () => {
expect(html).toMatch(/<img [^>]*class="clip"[^>]*src="assets\/frames\/[^"]+\.png"/);
expect(html).toMatch(/<audio [^>]*class="clip"[^>]*src="assets\/audio\/[^"]+"/);
expect(html).toMatch(/<video [^>]*class="clip"[^>]*src="assets\/media\/[^"]+"/);
expect(html).toContain('data-visual-kind="quiz-cover"');
expect(html).not.toContain('Quiz scenes are represented by markers');
});
it('lets unsupported authored fallback text resolve its own direction', () => {
const unsupported = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Unsupported direction' },
scenes: [
{
id: 'interactive',
stageId: 'stage',
title: 'Mixed fallback: English ثم العربية',
order: 0,
type: 'interactive',
content: { type: 'interactive' },
actions: [],
} as CompilerScene,
],
},
{ timing: stubProbe(), assets: stubAssets() },
);
const fallbackHtml = emitHyperframes(unsupported, { locale: 'ar-SA' }).files.find(
(file) => file.path === 'index.html',
)!.content;
expect(fallbackHtml).toMatch(
/<div dir="auto" style="font-size:2\.2vw[^"]*">Mixed fallback: English ثم العربية<\/div>/,
);
expect(fallbackHtml).toMatch(
/<div dir="auto" style="font-size:1\.2vw[^"]*">interactive-static-fallback/,
);
});
it('emits spotlight and laser overlays with authored params', () => {
expect(html).toContain('class="fx fx-spotlight"');
expect(html).toContain('class="fx fx-laser"');
expect(html).toContain('#00ff88');
});
it('matches the descriptor-authored spotlight fade easing for enter and exit', () => {
const dim = spotlightV1.layers.find((layer) => layer.id === 'dim');
const enter = dim?.tracks.find((track) => (track.phase ?? 'enter') === 'enter');
const exit = dim?.tracks.find((track) => track.phase === 'exit');
expect(enter?.easing).toEqual(exit?.easing);
if (enter?.easing?.type !== 'cubicBezier') {
throw new Error('spotlight.v1 dim enter track must use cubic-bezier easing');
}
const points = enter.easing.points.join(', ');
expect(html).toContain(`var EASE_SPOTLIGHT_FADE = cubicBezier(${points});`);
expect(html).toContain(
"tl.fromTo('#fx-0-1',{autoAlpha:0},{autoAlpha:1,duration:0.3,ease:EASE_SPOTLIGHT_FADE},2);",
);
expect(html).toContain(
"tl.to('#fx-0-1',{autoAlpha:0,duration:0.3,ease:EASE_SPOTLIGHT_FADE},4.7);",
);
});
it('does not burn in subtitles by default (clean video + sidecar files)', () => {
expect(html).not.toContain('id="subtitles"');
expect(html).not.toContain('id="subtitle-cue-0"');
expect(project.files.some((f) => f.path === 'subtitles.srt')).toBe(true);
expect(project.files.some((f) => f.path === 'subtitles.vtt')).toBe(true);
});
it('burns in a subtitle overlay when burnInSubtitles is enabled', () => {
const burned = emitHyperframes(ir, {
width: 1920,
height: 1080,
burnInSubtitles: true,
}).files.find((f) => f.path === 'index.html')!.content;
expect(burned).toContain('id="subtitles"');
expect(burned).toContain('id="subtitle-cue-0"');
expect(burned).toMatch(/id="subtitle-cue-0"[^>]*display:none/);
expect(burned).toMatch(/-webkit-line-clamp:2/);
const cue0Style = burned.match(/id="subtitle-cue-0"[^>]*style="([^"]*)"/)![1];
expect(cue0Style.match(/display:/g)).toHaveLength(1);
expect(cue0Style).toContain('display:none');
expect(burned).toMatch(/tl\.set\('#subtitle-cue-0',\{display:'-webkit-box'\},[\d.]+\);/);
expect(burned).toMatch(/tl\.set\('#subtitle-cue-0',\{display:'none'\},[\d.]+\);/);
expect(burned).toContain('Welcome to the lesson');
});
it('references vendored GSAP, never a CDN', () => {
expect(html).toContain('<script src="assets/vendor/gsap.min.js"></script>');
expect(project.gsapVendorPath).toBe('assets/vendor/gsap.min.js');
});
it('matches the HTML snapshot', () => {
expect(
html.replace(/data:font\/woff2;base64,[A-Za-z0-9+/=]+/, 'data:font/woff2;base64,<embedded>'),
).toMatchSnapshot();
});
});
describe('emitHyperframes frozen interactive HTML', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive export' },
scenes: [interactive('widget', '<!doctype html><h1>Widget</h1>')],
},
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: stubInteractiveHtml({
widget: {
id: 'interactive:widget',
present: true,
contentHash: 'b'.repeat(64),
},
}),
},
);
const html = emitHyperframes(ir, { width: 1280, height: 720 }).files.find(
(file) => file.path === 'index.html',
)!.content;
it('loads the packaged page in a script-only sandbox with the placeholder ready', () => {
expect(html).toContain('data-interactive-static-host');
expect(html).toContain('data-src="assets/interactive/001-widget.html"');
expect(html).toContain('sandbox="allow-scripts"');
expect(html).not.toMatch(/sandbox="[^"]*allow-same-origin/);
expect(html).toContain('data-interactive-fallback');
});
it('waits for every iframe to freeze or fall back before registering the timeline', () => {
const ready = html.indexOf(
'window.__openmaicInteractiveReady = initializeOpenMaicInteractiveStaticFrames()',
);
const registration = html.indexOf('window.__timelines["openmaic"] = tl;', ready);
expect(ready).toBeGreaterThan(0);
expect(registration).toBeGreaterThan(ready);
expect(html).toContain("'interactive-runtime-failure'");
expect(html).toContain("'interactive-ready-timeout'");
expect(html).toContain('window.__openmaicVideoDiagnostics');
expect(html).toContain('data-openmaic-runtime-diagnostics');
expect(html).toContain('window.__openmaicVideoManifest.runtimeDiagnostics');
});
it('keeps runtime diagnostics in the live report instead of an immutable sidecar', () => {
expect(html).toContain('data-openmaic-runtime-diagnostics');
expect(html).toContain('window.__openmaicVideoManifest.runtimeDiagnostics');
expect(html).not.toContain('openmaic-runtime-diagnostics.json');
});
it('keeps a later iframe visually inert outside its scene window', () => {
const guardedIr = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive clip visibility' },
scenes: [
slide('before-widget', [speech('before-widget-speech', 'Before the widget')]),
interactive(
'later-widget',
'<!doctype html><h1>Later widget</h1>',
[speech('later-widget-speech', 'The widget scene')],
1,
),
],
},
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: stubInteractiveHtml({
'later-widget': {
id: 'interactive:later-widget',
present: true,
contentHash: 'c'.repeat(64),
},
}),
},
);
const guardedHtml = emitHyperframes(guardedIr, { width: 1280, height: 720 }).files.find(
(file) => file.path === 'index.html',
)!.content;
const widget = guardedIr.scenes[1];
const start = widget.startMs / 1000;
const end = (widget.startMs + widget.durationMs) / 1000;
const hostTag = guardedHtml.match(
/<div id="scene-2-base"[^>]*data-interactive-static-host[^>]*>/,
)?.[0];
expect(hostTag).toBeDefined();
expect(hostTag).not.toContain('background:');
expect(guardedHtml).toContain(
'id="scene-2-base-content" data-interactive-static-visibility-wrapper',
);
expect(guardedHtml).toMatch(
/id="scene-2-base-content"[^>]*style="[^"]*visibility:hidden;opacity:0[^"]*"/,
);
expect(guardedHtml).toContain(`tl.set('#scene-2-base-content',{autoAlpha:1},${start});`);
expect(guardedHtml).toContain(`tl.set('#scene-2-base-content',{autoAlpha:0},${end});`);
});
});
describe('emitHyperframes static Quiz/PBL cover cards', () => {
const scenes = [
{
id: 'quiz-card',
stageId: 'stage',
title: 'Quiz <script>alert("title")</script>',
order: 0,
type: 'quiz',
content: {
type: 'quiz',
questions: [
{ id: 'q1', type: 'single', question: 'Hidden', points: 4, answer: ['SECRET_ANSWER'] },
{ id: 'q2', type: 'short_answer', question: 'Hidden too' },
],
},
actions: [],
},
{
id: 'pbl-card',
stageId: 'stage',
title: 'PBL fallback',
order: 1,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: 'Build & Verify <Systems>',
description:
'A deliberately long learner-facing description with SupercalifragilisticexpialidociousWithoutBreaks.',
gains: ['Model <threats>', 'Ship & verify'],
roles: [
{
id: 'instructor',
type: 'instructor',
name: 'Dr. <Guide>',
description: 'Helps you reason & test',
systemPrompt: 'SECRET_PERSONA',
},
],
milestones: [
{ id: 'm1', microtasks: [{ id: 't1' }, { id: 't2' }] },
{ id: 'm2', microtasks: [{ id: 't3' }] },
],
scenario: {
setting: 'SECRET_SITUATION',
characters: [
{
id: 'alex',
name: 'Alex & Sam',
persona: 'SECRET_CHARACTER_PERSONA',
},
],
},
threads: [{ messages: [{ content: 'SECRET_CHAT' }] }],
submissions: [{ content: 'SECRET_SUBMISSION' }],
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Cover Cards' }, scenes },
{ timing: stubProbe(), assets: stubAssets() },
);
const html = emitHyperframes(ir, { width: 1280, height: 720 }).files.find(
(file) => file.path === 'index.html',
)!.content;
it('emits each visual as a whole-scene track-0 clip with a static Quiz action', () => {
expect(html).toMatch(
/id="scene-1-visual-1" class="clip cover-card cover-quiz" data-visual-kind="quiz-cover" data-start="0" data-duration="2" data-track-index="0"/,
);
expect(html).toMatch(
/id="scene-2-visual-1" class="clip cover-card cover-pbl" data-visual-kind="pbl-cover" data-start="2" data-duration="2" data-track-index="0"/,
);
expect(html).toContain(
'<div class="cover-stat"><strong>2</strong><span>questions</span></div>',
);
expect(html).toContain('<div class="cover-stat"><strong>5</strong><span>pts</span></div>');
expect(html).not.toContain('cover-button');
expect(html).not.toContain('<button');
expect(html).not.toContain('open.maic.chat');
});
it('renders the PBL Hero summary from learner-visible design fields', () => {
expect(html).toContain('Build & Verify <Systems>');
expect(html).toContain('Model <threats>');
expect(html).toContain('Ship & verify');
expect(html).toContain('<div class="cover-stat"><strong>2</strong><span>Stages</span></div>');
expect(html).toContain('<div class="cover-stat"><strong>3</strong><span>Tasks</span></div>');
expect(html).toContain('Dr. <Guide>');
expect(html).toContain('Helps you reason & test');
expect(html).toContain('Alex & Sam');
expect(html).toContain('Role-play character');
});
it('renders every label from the injected locale strings', () => {
const localized = emitHyperframes(ir, {
width: 1280,
height: 720,
labels: {
quiz: '随堂测验',
questions: '道题',
points: '分',
pbl: '项目实战',
stages: '阶段',
tasks: '任务',
gains: '你将收获',
instructor: '导师',
instructorTagline: '全程陪伴你完成项目',
scenarioCharacter: '模拟角色',
scenarioCharacterTagline: '情景模拟中与你互动的角色',
},
}).files.find((file) => file.path === 'index.html')!.content;
expect(localized).toContain('<span class="cover-eyebrow-icon">?</span> 随堂测验');
expect(localized).toContain('<strong>2</strong><span>道题</span>');
expect(localized).toContain('<strong>5</strong><span>分</span>');
expect(localized).toContain('你将收获');
expect(localized).toContain('<span>导师</span>');
expect(localized).toContain('<span>模拟角色</span>');
expect(localized).not.toContain('<span>pts</span>');
expect(localized).not.toContain("What you'll gain");
});
it('keeps document direction unset and scopes locale direction to cover panels', () => {
const chinese = emitHyperframes(ir, { width: 1280, locale: 'zh-CN' }).files.find(
(file) => file.path === 'index.html',
)!.content;
const arabic = emitHyperframes(ir, { width: 1280, locale: 'ar-SA' }).files.find(
(file) => file.path === 'index.html',
)!.content;
expect(chinese).toContain('<html lang="zh-CN">');
expect(arabic).toContain('<html lang="ar-SA">');
expect(arabic).not.toMatch(/<html[^>]*\sdir=["'](?:rtl|auto)["']/);
expect(arabic).toContain('<div class="cover-panel cover-quiz-panel" dir="rtl">');
});
it('records the options the HTML was emitted with', () => {
const labels = {
quiz: 'Quiz label',
questions: 'Question label',
points: 'Point label',
pbl: 'PBL label',
stages: 'Stage label',
tasks: 'Task label',
gains: 'Gain label',
instructor: 'Instructor label',
instructorTagline: 'Instructor tagline',
scenarioCharacter: 'Character label',
scenarioCharacterTagline: 'Character tagline',
quizCtaPrompt: 'Quiz CTA prompt',
pblCtaPrompt: 'PBL CTA prompt',
ctaVisit: 'CTA visit label',
};
const readme = emitHyperframes(ir, {
width: 1280,
height: 720,
compositionId: 'cover-cards',
gsapVendorPath: 'vendor/gsap.js',
manifestPath: 'timeline.json',
locale: 'zh-CN',
burnInSubtitles: true,
labels,
cta: { destination: 'www.example.test/learn' },
}).files.find((file) => file.path === 'README.md')!.content;
expect(readme).toContain('| Locale | <code>"zh-CN"</code> (card chrome, `<html lang>`) |');
expect(readme).toContain('| Burned-in subtitles | `true` |');
expect(readme).toContain('| Resolution | `1280×720` |');
expect(readme).toContain('| Composition ID | <code>"cover-cards"</code> |');
expect(readme).toContain('| Manifest path | <code>"timeline.json"</code> |');
expect(readme).toContain('| GSAP path | <code>"vendor/gsap.js"</code> |');
expect(readme).toContain('| CTA destination | <code>"www.example.test/learn"</code> |');
for (const [key, value] of Object.entries(labels)) {
expect(readme).toContain(`"${key}": ${JSON.stringify(value)}`);
}
expect(readme).toContain(
'same manifest, emitter implementation, and complete effective options produce byte-identical HTML',
);
expect(readme).toContain(
'different hosts do not guarantee identical non-Latin pixels because system fonts may differ',
);
const disabled = emitHyperframes(ir).files.find((file) => file.path === 'README.md')!.content;
expect(disabled).toContain('| CTA destination | <code>disabled</code> |');
expect(disabled).toContain('"quiz": "Quiz"');
expect(disabled).toContain('"ctaVisit": "Visit"');
});
it('records adversarial public strings without breaking Markdown tables or fences', () => {
const readme = emitHyperframes(ir, {
width: 1280,
height: 720,
compositionId: 'cover|````````id\nnext',
manifestPath: 'manifest|`timeline`.json\nnext',
gsapVendorPath: 'vendor/<gsap>|``.js\nnext',
locale: 'en|`US`\nnext',
labels: {
quiz: 'Quiz | <unsafe> & `tick` then `````` fence\nnext',
},
cta: { destination: 'www.example.test/|`learn`\nnext' },
}).files.find((file) => file.path === 'README.md')!.content;
const optionsTable = readme.slice(
readme.indexOf('| Option | Value |'),
readme.indexOf('### Effective cover labels'),
);
expect(optionsTable.split('\n').filter((line) => line.startsWith('|'))).toHaveLength(9);
expect(optionsTable).toContain(
'| Composition ID | <code>"cover|````````id\\nnext"</code> |',
);
expect(optionsTable).toContain(
'| Manifest path | <code>"manifest|`timeline`.json\\nnext"</code> |',
);
expect(optionsTable).toContain(
'| GSAP path | <code>"vendor/<gsap>|``.js\\nnext"</code> |',
);
expect(optionsTable).toContain(
'| CTA destination | <code>"www.example.test/|`learn`\\nnext"</code> |',
);
expect(optionsTable).toContain(
'| Locale | <code>"en|`US`\\nnext"</code> (card chrome, `<html lang>`) |',
);
expect(readme).toContain(
'one data-composition-id=<code>"cover|````````id\\nnext"</code> stage',
);
expect(readme).toContain(
'- <code>"manifest|`timeline`.json\\nnext"</code> — the `VideoTimeline` manifest',
);
expect(readme).toContain(
'- <code>"vendor/<gsap>|``.js\\nnext"</code> — vendored GSAP',
);
expect(readme).not.toContain('- `manifest|');
expect(readme).not.toContain('- `vendor/<gsap>');
expect(readme).toContain('`````````json\n{');
expect(readme).toContain('\n`````````\n\n## Verify');
expect(readme).toContain('"quiz": "Quiz | <unsafe> & `tick` then `````` fence\\nnext"');
expect(readme).not.toContain('\n```json\n');
});
it('renders an escaped, non-interactive, two-line CTA throughout Quiz and PBL covers', () => {
const ctaHtml = emitHyperframes(ir, {
width: 1280,
height: 720,
labels: {
quizCtaPrompt: 'Try the quiz',
pblCtaPrompt: 'Explore the project',
ctaVisit: 'Visit',
},
cta: { destination: 'www.example.test/<learn>' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(ctaHtml.match(/class="cover-cta"/g)).toHaveLength(2);
expect(ctaHtml.match(/class="cover-cta-line"/g)).toHaveLength(4);
expect(ctaHtml).toContain('<div class="cover-cta-line">Try the quiz</div>');
expect(ctaHtml).toContain('<div class="cover-cta-line">Explore the project</div>');
expect(
ctaHtml.match(/Visit <bdi dir="ltr">www\.example\.test\/<learn><\/bdi>/g),
).toHaveLength(2);
expect(ctaHtml).not.toContain('<a');
expect(ctaHtml).not.toContain('<button');
expect(ctaHtml).not.toContain('href=');
expect(ctaHtml).not.toContain('onclick=');
expect(ctaHtml).not.toMatch(/tl\.[^(]+\([^)]*cover-cta/);
const quizStart = ctaHtml.indexOf('class="clip cover-card cover-quiz"');
const pblStart = ctaHtml.indexOf('class="clip cover-card cover-pbl"');
const quizStats = ctaHtml.indexOf('class="cover-stats cover-quiz-stats"', quizStart);
const quizCta = ctaHtml.indexOf('class="cover-cta"', quizStats);
const pblMeta = ctaHtml.indexOf('class="cover-pbl-meta"', pblStart);
const pblCta = ctaHtml.indexOf('class="cover-cta"', pblMeta);
expect(quizStart).toBeLessThan(quizStats);
expect(quizStats).toBeLessThan(quizCta);
expect(quizCta).toBeLessThan(pblStart);
expect(pblStart).toBeLessThan(pblMeta);
expect(pblMeta).toBeLessThan(pblCta);
const disabledHtml = emitHyperframes(ir, { cta: null }).files.find(
(file) => file.path === 'index.html',
)!.content;
expect(disabledHtml).not.toContain('class="cover-cta"');
});
it('lets authored cover and subtitle leaves resolve their own direction', () => {
const bidi = emitHyperframes(ir, {
width: 1280,
height: 720,
locale: 'ar-SA',
cta: { destination: 'www.example.test' },
}).files.find((file) => file.path === 'index.html')!.content;
const subtitleBidi = emitHyperframes(compileSample(), {
width: 1280,
height: 720,
burnInSubtitles: true,
}).files.find((file) => file.path === 'index.html')!.content;
expect(bidi.match(/<h1 class="cover-title" dir="auto">/g)).toHaveLength(2);
expect(bidi).toContain('<p class="cover-description" dir="auto"');
expect(bidi.match(/class="cover-gain-text" dir="auto"/g)).toHaveLength(2);
expect(bidi).toContain('<strong dir="auto">Dr. <Guide></strong>');
expect(bidi).toContain('<small dir="auto">Helps you reason & test</small>');
expect(subtitleBidi).toMatch(/id="subtitle-cue-0"[^>]*dir="auto"/);
expect(bidi).toContain('<html lang="ar-SA">');
expect(bidi).not.toMatch(/<html[^>]*\sdir=["'](?:rtl|auto)["']/);
expect(bidi.match(/class="cover-panel [^"]+" dir="rtl"/g)).toHaveLength(2);
expect(bidi).toContain('<bdi dir="ltr">www.example.test</bdi>');
expect(bidi).toContain('<div class="cover-eyebrow"><span');
expect(bidi).not.toContain('class="cover-eyebrow" dir="auto"');
expect(bidi).not.toContain('class="cover-section-label" dir="auto"');
});
it('omits the people row entirely when the project authored no instructor', () => {
const bare = [
{
id: 'pbl-bare',
stageId: 'stage',
title: 'Bare PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: { title: 'Bare', description: '', roles: [], milestones: [] },
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const bareIr = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Bare' }, scenes: bare },
{ timing: stubProbe(), assets: stubAssets() },
);
const bareHtml = emitHyperframes(bareIr, {
width: 1280,
labels: { instructor: '导师', instructorTagline: '全程陪伴你完成项目' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(bareHtml).not.toContain('<div class="cover-people">');
expect(bareHtml).not.toContain('cover-person-instructor');
expect(bareHtml).not.toContain('全程陪伴你完成项目');
expect(bareHtml).toContain('cover-stats');
});
it('keeps the instructor row when the project authored one, with a tagline fallback', () => {
const named = [
{
id: 'pbl-named',
stageId: 'stage',
title: 'Named PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: 'Named',
description: '',
roles: [{ id: 'i', type: 'instructor', name: '林教练' }],
milestones: [],
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const namedIr = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Named' }, scenes: named },
{ timing: stubProbe(), assets: stubAssets() },
);
const namedHtml = emitHyperframes(namedIr, {
width: 1280,
labels: { instructor: '导师', instructorTagline: '全程陪伴你完成项目' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(namedHtml).toContain(
'<strong dir="auto">林教练</strong><small dir="auto">全程陪伴你完成项目</small>',
);
expect(namedHtml).toContain('<div class="cover-avatar">林</div>');
});
it('escapes authored text, excludes runtime/internal data, and clamps pathological text', () => {
expect(html).toContain('Quiz <script>alert("title")</script>');
expect(html).not.toContain('<script>alert("title")</script>');
expect(html).not.toMatch(/SECRET_(ANSWER|PERSONA|SITUATION|CHARACTER_PERSONA|CHAT|SUBMISSION)/);
expect(html).toContain('overflow-wrap:anywhere');
expect(html).toContain('-webkit-line-clamp:3');
expect(html).toContain('-webkit-line-clamp:2');
});
* The stage is a fixed-pixel box, so a card written in raw px would render at
* 720p type size inside a 4K frame. Every cover length must scale with the
* render width — and never with the browser viewport (`vw`), which differs
* between `hyperframes preview` and `hyperframes render`.
*/
it('scales cover typography and panel width with the render resolution', () => {
const at = (width: number) =>
emitHyperframes(ir, { width }).files.find((file) => file.path === 'index.html')!.content;
const hd = at(1280);
const uhd = at(3840);
expect(hd).toContain('.cover-stat strong { color:#fff;font-size:28px');
expect(uhd).toContain('.cover-stat strong { color:#fff;font-size:84px');
expect(hd).toContain('font-size:52px;line-height:1.08');
expect(uhd).toContain('font-size:156px;line-height:1.08');
expect(hd).toContain('width:min(88%,1040px)');
expect(uhd).toContain('width:min(88%,3120px)');
expect(hd).toMatch(/\.cover-person-copy span \{[^}]*font-size:9px/);
expect(uhd).toMatch(/\.cover-person-copy span \{[^}]*font-size:27px/);
expect(hd).toContain('border:1px solid rgba(255,255,255,.13)');
expect(uhd).toContain('border:3px solid rgba(255,255,255,.13)');
for (const css of [hd, uhd]) {
expect(css).not.toMatch(/\.cover-[\w-]+[^}]*\d(?:\.\d+)?vw/);
expect(css).not.toContain('clamp(');
}
});
});
* A PBL card carrying every supported field in long CJK is the densest thing the
* exporter can produce. The panel is a fixed box, so the emitter decides which
* sections survive — scene type, title and the stage/task counts always do, the
* detail rows give way in reverse priority (#995 review).
*/
describe('emitHyperframes cover cards at constrained resolutions', () => {
const dense = [
{
id: 'pbl-dense',
stageId: 'stage',
title: 'PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: '构建一个确定性、离线可验证且支持超长中文标题排版的课程渲染导出流水线',
description:
'本项目要求你从设计数据出发,完成建模、实现与验证三个阶段,并在真实的 720p 与 480p 导出中确认排版不会撑破画面,同时保证字幕与封面共存。',
gains: [
'把设计期数据与运行时状态彻底分离,确保导出结果可复现',
'验证 HTML 转义、时间边界与超长英文单词换行的实际表现',
'在无网络条件下生成可复现的视频产物并通过 lint 校验',
'为受限分辨率定义信息优先级,避免面板被静默裁切',
],
roles: [
{
id: 'instructor',
type: 'instructor',
name: '导出教练',
description: '帮助你逐步推进确定性渲染、排查边界并完成发布验证。',
},
],
milestones: [
{ id: 'm1', microtasks: [{ id: 't1' }, { id: 't2' }, { id: 't3' }] },
{ id: 'm2', microtasks: [{ id: 't4' }, { id: 't5' }] },
{ id: 'm3', microtasks: [{ id: 't6' }] },
],
scenario: { characters: [{ id: 'reviewer', name: '发布评审员' }] },
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Dense' }, scenes: dense },
{ timing: stubProbe(), assets: stubAssets() },
);
const at = (width: number, height: number) =>
emitHyperframes(ir, { width, height }).files.find((file) => file.path === 'index.html')!
.content;
it('preserves five gains before layout degradation is necessary', () => {
const fiveGainScene = [
{
id: 'pbl-five-gains',
stageId: 'stage',
title: 'PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: 'Compact project',
description: 'A compact project description.',
gains: ['Gain one', 'Gain two', 'Gain three', 'Gain four', 'Gain five'],
roles: [],
milestones: [{ id: 'm1', microtasks: [{ id: 't1' }] }],
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const fiveGainIr = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Five gains' }, scenes: fiveGainScene },
{ timing: stubProbe(), assets: stubAssets() },
);
const fiveGainHtml = emitHyperframes(fiveGainIr, {
width: 1280,
height: 720,
cta: { destination: 'open.maic.chat' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(fiveGainHtml.match(/class="cover-gain-text"/g)).toHaveLength(5);
expect(fiveGainHtml).toContain('Gain five');
expect(fiveGainHtml).toContain('<strong>1</strong><span>Stages</span>');
expect(fiveGainHtml).toContain('<div class="cover-cta">');
});
it('degrades optional PBL content in the people, 5→4→2→0, description order', () => {
const ladderScene = [
{
id: 'pbl-degradation-ladder',
stageId: 'stage',
title: 'PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: 'A compact project title',
description:
'A description long enough to occupy three planned lines while the frame height decreases through every degradation threshold in one-pixel steps.',
gains: [
'First compact gain',
'Second compact gain',
'Third compact gain',
'Fourth compact gain',
'Fifth compact gain',
],
roles: [
{
id: 'instructor',
type: 'instructor',
name: 'Export Coach',
description: 'Keeps the project moving.',
},
],
milestones: [{ id: 'm1', microtasks: [{ id: 't1' }] }],
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const ladderIr = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Degradation ladder' }, scenes: ladderScene },
{ timing: stubProbe(), assets: stubAssets() },
);
const signatures: string[] = [];
for (let height = 900; height >= 260; height -= 1) {
const ladderHtml = emitHyperframes(ladderIr, {
width: 1280,
height,
cta: { destination: 'open.maic.chat' },
}).files.find((file) => file.path === 'index.html')!.content;
const people = ladderHtml.includes('<div class="cover-people">') ? 'people' : 'no-people';
const gains = ladderHtml.match(/class="cover-gain-text"/g)?.length ?? 0;
const description = ladderHtml.match(
/class="cover-description" dir="auto" style="-webkit-line-clamp:(\d)"/,
)?.[1];
const signature = `${people}/${gains}/${description ?? 0}`;
if (signatures.at(-1) !== signature) signatures.push(signature);
}
expect(signatures).toEqual([
'people/5/3',
'no-people/5/3',
'no-people/4/3',
'no-people/2/3',
'no-people/0/3',
'no-people/0/2',
'no-people/0/0',
]);
const shortest = emitHyperframes(ladderIr, {
width: 1280,
height: 260,
cta: { destination: 'open.maic.chat' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(shortest).toContain('<strong>1</strong><span>Stages</span>');
expect(shortest).toContain('<div class="cover-cta">');
});
it('uses the 0.64 digit width at the people-retention planner threshold', () => {
const digitThresholdScene = [
{
id: 'pbl-digit-threshold',
stageId: 'stage',
title: 'PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
title: '0123456789012345678901234',
description: '',
gains: [],
roles: [
{
id: 'instructor',
type: 'instructor',
name: 'Digit Coach',
description: 'Keeps this threshold observable.',
},
],
milestones: [{ id: 'm1', microtasks: [{ id: 't1' }] }],
},
},
actions: [],
},
] as Parameters<typeof compileVideoTimeline>[0]['scenes'];
const digitThresholdIr = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Digit threshold' }, scenes: digitThresholdScene },
{ timing: stubProbe(), assets: stubAssets() },
);
const digitThresholdHtml = emitHyperframes(digitThresholdIr, {
width: 1280,
height: 540,
cta: { destination: 'open.maic.chat' },
}).files.find((file) => file.path === 'index.html')!.content;
expect(digitThresholdHtml).not.toContain('<div class="cover-people">');
expect(digitThresholdHtml).toContain('<strong>1</strong><span>Stages</span>');
expect(digitThresholdHtml).toContain('<div class="cover-cta">');
});
it('keeps the whole card at 1280x720', () => {
const hd = at(1280, 720);
expect(hd).toContain('构建一个确定性');
expect(hd).toContain('<strong>3</strong><span>Stages</span>');
expect(hd).toContain('<strong>6</strong><span>Tasks</span>');
expect(hd).toContain("What you'll gain");
expect(hd).toContain('导出教练');
expect(hd).toContain('发布评审员');
});
it('drops the people row at 854x480, where its caption type stops being legible', () => {
const sd = at(854, 480);
expect(sd).toContain('构建一个确定性');
expect(sd).toContain('<strong>3</strong><span>Stages</span>');
expect(sd).toContain('<strong>6</strong><span>Tasks</span>');
expect(sd).not.toContain('<div class="cover-people">');
expect(sd).not.toContain('导出教练');
expect(sd).not.toContain('发布评审员');
});
it('sheds gains before the title and counts when the panel runs out of room', () => {
const squat = at(1280, 480);
expect(squat).toContain('构建一个确定性');
expect(squat).toContain('<strong>3</strong><span>Stages</span>');
expect(squat).not.toContain('<div class="cover-people">');
expect(squat).not.toContain("What you'll gain");
});
});
describe('emitHyperframes multi-cue subtitle positioning (regression)', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Multi Cue' },
scenes: [
slide(
'intro',
[
speech('sp1', 'First caption line', { audioId: 'a1' }),
speech('sp2', 'Second caption line', { audioId: 'a2' }),
speech('sp3', 'Third caption line', { audioId: 'a3' }),
],
{ title: 'Intro', elements: [] },
),
],
},
{
timing: stubProbe({ sp1: 2000, sp2: 2000, sp3: 2000 }, {}),
assets: stubAssets({ sp1: audioMeta('a1'), sp2: audioMeta('a2'), sp3: audioMeta('a3') }, {}),
},
);
const html = emitHyperframes(ir, { width: 1920, height: 1080, burnInSubtitles: true }).files.find(
(f) => f.path === 'index.html',
)!.content;
it('stacks every cue in one grid cell so the active cue never shifts', () => {
const cueCount = (html.match(/id="subtitle-cue-\d+"/g) ?? []).length;
expect(cueCount).toBe(3);
expect(html.match(/grid-area:1\/1/g)?.length).toBe(3);
expect(html).toContain('id="subtitles" style="position:absolute');
expect(html).toMatch(/id="subtitles"[^>]*display:grid/);
});
it('removes inactive cues from layout (display:none, never visibility:hidden)', () => {
for (let i = 0; i < 3; i++) {
expect(html).toMatch(new RegExp(`id="subtitle-cue-${i}"[^>]*display:none`));
expect(html).toContain(`tl.set('#subtitle-cue-${i}',{display:'-webkit-box'}`);
expect(html).toContain(`tl.set('#subtitle-cue-${i}',{display:'none'}`);
}
expect(html).not.toContain('visibility:hidden');
expect(html).not.toContain("visibility:'hidden'");
});
});
describe('emitHyperframes determinism red-lines (hyperframes lint proxy)', () => {
const project = emitHyperframes(compileSample());
const html = project.files.find((f) => f.path === 'index.html')!.content;
it('loads no script or asset from an http(s) origin (no CDN)', () => {
expect(html).not.toMatch(/src="https?:\/\//);
});
it('embeds its primary font so Hyperframes never resolves Google Fonts', () => {
expect(html).toContain('@font-face');
expect(html).toContain('font-family: "Inter"');
expect(html).toMatch(/src:\s*url\("data:font\/woff2;base64,[A-Za-z0-9+/=]+"\)/);
expect(html).not.toContain('fonts.googleapis.com');
expect(html).not.toContain('fonts.gstatic.com');
});
it('uses no non-deterministic runtime APIs', () => {
expect(html).not.toContain('Date.now');
expect(html).not.toContain('Math.random');
expect(html).not.toContain('requestAnimationFrame');
expect(html).not.toContain('setTimeout');
expect(html).not.toContain('setInterval');
});
it('uses no infinite repeats (finite ring pulse only)', () => {
expect(html).not.toContain('repeat:-1');
expect(html).not.toContain('repeat: -1');
expect(html).not.toContain('Infinity');
});
it('declares an explicit root duration and extends the timeline to it', () => {
expect(html).toMatch(/data-duration="[\d.]+"/);
expect(html).toMatch(/tl\.set\(\{\}, \{\}, [\d.]+\);/);
});
});