Files
openmaic/OpenMAIC/tests/video-export/cover-card-layout.browser.test.ts
2026-08-16 14:58:47 +08:00

1103 lines
38 KiB
TypeScript

/**
* Cover-card layout guardrail — the estimator, checked against Chromium.
*
* The emitter plans optional PBL content without laying it out. This suite is
* the browser proof that every planned card fits at the two supported compact
* frame sizes, keeps core counts/CTA content, and preserves bidi boundaries.
*
* Run with `COVER_LAYOUT_BROWSER=1`; missing Chromium is then a hard failure.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { chromium, type Browser, type Page, type Request } from '@playwright/test';
import { compileVideoTimeline, emitHyperframes, prepareQuizQuestionList } from '@/lib/video-export';
import type { CompilerScene, QuizLayoutProbe, VideoExportCta } from '@/lib/video-export';
import { PBL_PANEL_DESIGN_BOX } from '@/lib/video-export/emit-hyperframes';
import {
getVideoExportCoverLabels,
resolveVideoExportCta,
} from '@/lib/video-export-app/cover-config';
import {
createQuizQuestionListMeasurementSurface,
measureQuizQuestionList,
} from '@/lib/video-export-app/quiz-layout';
import type { Locale } from '@/lib/i18n';
import { NO_ASSETS, NO_PROBE, speech } from './helpers';
import {
QUIZ_SCROLL_LAYOUT_720P,
QUIZ_SCROLL_QUESTIONS,
QUIZ_SCROLL_TITLE,
quizScrollScene,
} from './quiz-scroll-fixture';
const REQUIRED = process.env.COVER_LAYOUT_BROWSER === '1';
const FRAMES = {
'720p': { width: 1280, height: 720 },
'480p': { width: 854, height: 480 },
} as const;
const QUIZ_LIST_FRAMES = {
'720p': { width: 1280, height: 720 },
'1080p': { width: 1920, height: 1080 },
'4k': { width: 3840, height: 2160 },
} as const;
interface CoverContent {
title: string;
description: string;
gains: string[];
}
const CONTENT = {
compactFive: {
title: 'Compact deterministic project',
description: '',
gains: ['Model data', 'Implement output', 'Verify layout', 'Explain tradeoffs', 'Ship safely'],
},
cjk: {
title: '构建一个确定性、离线可验证且支持超长中文标题排版的课程渲染导出流水线',
description:
'本项目要求你从设计数据出发,完成建模、实现与验证三个阶段,并在真实的 720p 与 480p 导出中确认排版不会撑破画面,同时保证字幕与封面共存。',
gains: [
'把设计期数据与运行时状态彻底分离,确保导出结果可复现',
'验证 HTML 转义、时间边界与超长英文单词换行的实际表现',
'在无网络条件下生成可复现的视频产物并通过 lint 校验',
'为受限分辨率定义信息优先级,避免面板被静默裁切',
'记录完整有效选项,让后续导出可以逐项核对',
],
},
repeatedM: {
title: 'mmmm '.repeat(18),
description: 'mmmm '.repeat(56),
gains: Array.from({ length: 5 }, () => 'mmmm'.repeat(18)),
},
repeatedW: {
title: 'wwww '.repeat(18),
description: 'wwww '.repeat(56),
gains: Array.from({ length: 5 }, () => 'wwww'.repeat(18)),
},
uppercaseW: {
title: 'W'.repeat(40),
description: 'WWWW '.repeat(48),
gains: Array.from({ length: 5 }, () => 'W'.repeat(36)),
},
uppercaseM: {
title: 'M'.repeat(40),
description: 'MMMM '.repeat(48),
gains: Array.from({ length: 5 }, () => 'M'.repeat(36)),
},
digits: {
title: '0123456789'.repeat(4),
description: '',
gains: [],
},
emoji: {
title: '🚀'.repeat(17),
description: '🚀'.repeat(86),
gains: Array.from({ length: 5 }, () => '🚀'.repeat(31)),
},
english: {
title: 'Build a deterministic offline learning-video export',
description:
'Move from authored design data through implementation and browser verification without relying on learner runtime state.',
gains: Array.from(
{ length: 5 },
(_, index) => `Verify deterministic learner-facing output ${index + 1}`,
),
},
arabic: {
title: 'مرحبا بك في مشروع التصدير الحتمي للفيديو التعليمي المفتوح المصدر',
description:
'يتطلب هذا المشروع منك الانتقال من بيانات التصميم إلى النمذجة والتنفيذ والتحقق، والتأكد من أن التخطيط لا يتجاوز حدود الإطار عند التصدير.',
gains: Array.from(
{ length: 5 },
() => 'افصل بيانات التصميم عن حالة التشغيل لضمان إمكانية إعادة إنتاج المخرجات',
),
},
} satisfies Record<string, CoverContent>;
const DEFAULT_CTA = resolveVideoExportCta(undefined)!;
const WWW_CTA = resolveVideoExportCta('https://www.example.test/learn/')!;
const MAX_WIDTH_CTA = resolveVideoExportCta(`a.co/${'W'.repeat(91)}`)!;
const MAX_UNICODE_CTA_RAW = `a.co/${'学'.repeat(91)}`;
const MAX_UNICODE_CTA = resolveVideoExportCta(MAX_UNICODE_CTA_RAW)!;
interface CoverOptions {
kind: 'quiz' | 'pbl';
locale: Locale;
cta: VideoExportCta;
burnInSubtitles?: boolean;
}
function coverHtml(
content: CoverContent,
frame: { width: number; height: number },
options: CoverOptions,
): string {
const scene =
options.kind === 'quiz'
? {
id: 'quiz',
stageId: 'stage',
title: content.title,
order: 0,
type: 'quiz',
content: {
type: 'quiz',
questions: [
{ id: 'q1', type: 'single', question: 'Hidden', points: 3, answer: ['Hidden'] },
{ id: 'q2', type: 'short_answer', question: 'Hidden', points: 2 },
],
},
actions: options.burnInSubtitles
? [speech('narration', 'A two-line subtitle must remain clear of the cover panel.')]
: [],
}
: {
id: 'pbl',
stageId: 'stage',
title: 'PBL',
order: 0,
type: 'pbl',
content: {
type: 'pbl',
projectV2: {
...content,
roles: [
{
id: 'instructor',
type: 'instructor',
name: 'Export Coach 导出教练',
description: '帮助你逐步推理确定性渲染、排查边界并完成发布前验证。',
},
],
milestones: [
{ id: 'design', microtasks: [{ id: 'model' }, { id: 'test' }] },
{ id: 'verify', microtasks: [{ id: 'render' }] },
],
scenario: {
characters: [{ id: 'reviewer', name: 'Release Reviewer 发布评审员' }],
},
},
},
actions: options.burnInSubtitles
? [
speech(
'narration',
'字幕与封面同时出现时,面板必须停在字幕带上方,而不是被它盖住。',
),
]
: [],
};
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'cover-layout' }, scenes: [scene as CompilerScene] },
{ timing: NO_PROBE, assets: NO_ASSETS },
);
return emitHyperframes(ir, {
...frame,
locale: options.locale,
labels: getVideoExportCoverLabels(options.locale),
cta: options.cta,
burnInSubtitles: options.burnInSubtitles === true,
}).files.find((file) => file.path === 'index.html')!.content;
}
const LONG_QUIZ_QUESTIONS = QUIZ_SCROLL_QUESTIONS;
function quizListHtml(
frame: { width: number; height: number },
questions: readonly unknown[],
measurement: { contentHeightPx: number; viewportHeightPx: number },
): {
html: string;
scrollDistancePx: number;
timing: {
transitionStartMs: number;
transitionEndMs: number;
topHoldEndMs: number;
scrollEndMs: number;
bottomHoldEndMs: number;
};
} {
const source = {
id: 'quiz-list',
stageId: 'stage',
title: QUIZ_SCROLL_TITLE,
order: 0,
type: 'quiz',
content: { type: 'quiz', questions },
actions: [speech('quiz-list-narration', 'Question-list narration')],
} as CompilerScene;
const probe: QuizLayoutProbe = {
measureQuestionList: () => ({ ...measurement, frameHeightPx: frame.height }),
};
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'quiz-list-layout' }, scenes: [source] },
{ timing: NO_PROBE, assets: NO_ASSETS, quizLayout: probe },
);
const visual = ir.scenes[0].visuals.find((item) => item.kind === 'quiz-question-list');
if (!visual || visual.kind !== 'quiz-question-list') throw new Error('Quiz list not planned');
return {
html: emitHyperframes(ir, {
...frame,
locale: 'en-US',
labels: getVideoExportCoverLabels('en-US'),
}).files.find((file) => file.path === 'index.html')!.content,
scrollDistancePx: visual.scrollDistancePx,
timing: {
transitionStartMs: visual.startMs,
transitionEndMs: visual.startMs + visual.transitionDurationMs,
topHoldEndMs: visual.startMs + visual.transitionDurationMs + visual.topHoldDurationMs,
scrollEndMs:
visual.startMs +
visual.transitionDurationMs +
visual.topHoldDurationMs +
visual.scrollDurationMs,
bottomHoldEndMs: visual.startMs + visual.durationMs,
},
};
}
const GSAP = readFileSync(join(process.cwd(), 'public/vendor/gsap.min.js'), 'utf8');
const HARNESS_URL = 'http://cover-layout.test/index.html';
interface Measurement {
overflow: number;
panelTop: number;
panelBottom: number;
stageHeight: number;
captionTop: number | null;
statTiles: Array<{ count: string; label: string }>;
ctaDirection: string | null;
ctaLineTexts: string[];
ctaLineHorizontalOverflows: number[];
ctaLineVerticalOverflows: number[];
destinationFragmentHorizontalOverflows: number[];
interactiveCtaCount: number;
gainCount: number;
peopleCount: number;
descriptionCount: number;
titleDirection: string;
chromeDirection: string;
panelWidth: number;
panelPaddingY: number;
panelContentWidth: number;
}
async function loadEmittedHtml(page: Page, html: string): Promise<void> {
const pageErrors: string[] = [];
const gsapRequests: string[] = [];
const recordPageError = (error: Error) => pageErrors.push(error.message);
const recordRequest = (request: Request) => {
if (request.url().endsWith('/assets/vendor/gsap.min.js')) gsapRequests.push(request.url());
};
page.on('pageerror', recordPageError);
page.on('request', recordRequest);
try {
// `setContent` keeps the current document URL. Establish a controlled HTTP
// base first so the production-relative vendored GSAP path is actually
// requested and executed, just as it is from an unpacked export ZIP.
await page.goto(HARNESS_URL, { waitUntil: 'load' });
await page.setContent(html, { waitUntil: 'load' });
await page.evaluate(async () => {
await document.fonts.ready;
});
const runtime = await page.evaluate(() => {
const globals = window as typeof window & {
gsap?: unknown;
__timelines?: Record<string, { duration?: unknown }>;
};
return {
gsap: typeof globals.gsap,
timelineKeys: Object.keys(globals.__timelines ?? {}),
openmaicTimeline: typeof globals.__timelines?.openmaic?.duration === 'function',
};
});
expect(pageErrors).toEqual([]);
expect(gsapRequests).toEqual(['http://cover-layout.test/assets/vendor/gsap.min.js']);
expect(runtime).toEqual({
gsap: 'object',
timelineKeys: ['openmaic'],
openmaicTimeline: true,
});
} finally {
page.off('pageerror', recordPageError);
page.off('request', recordRequest);
}
}
async function measure(
page: Page,
html: string,
frame: { width: number; height: number },
): Promise<Measurement> {
await page.setViewportSize(frame);
await loadEmittedHtml(page, html);
return page.evaluate(() => {
const stage = document.querySelector<HTMLElement>('[data-composition-id]')!;
const panel = document.querySelector<HTMLElement>('.cover-panel')!;
const title = document.querySelector<HTMLElement>('.cover-title')!;
const chrome = document.querySelector<HTMLElement>('.cover-eyebrow')!;
const destination = document.querySelector<HTMLElement>('.cover-cta bdi');
const cue = document.querySelector<HTMLElement>('#subtitle-cue-0');
if (cue) cue.style.display = '-webkit-box';
const stageBox = stage.getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const panelStyle = getComputedStyle(panel);
const ctaLines = Array.from(document.querySelectorAll<HTMLElement>('.cover-cta-line'));
const destinationLine = destination?.closest<HTMLElement>('.cover-cta-line') ?? null;
const destinationLineBox = destinationLine?.getBoundingClientRect() ?? null;
let captionTop: number | null = null;
if (cue) {
const cueStyle = getComputedStyle(cue);
const reserved =
parseFloat(cueStyle.maxHeight) +
parseFloat(cueStyle.paddingTop) +
parseFloat(cueStyle.paddingBottom);
captionTop = cue.getBoundingClientRect().bottom - stageBox.top - reserved;
}
return {
overflow: panel.scrollHeight - panel.clientHeight,
panelTop: panelBox.top - stageBox.top,
panelBottom: panelBox.bottom - stageBox.top,
stageHeight: stageBox.height,
captionTop,
statTiles: Array.from(document.querySelectorAll<HTMLElement>('.cover-stat')).map((tile) => ({
count: tile.querySelector('strong')?.textContent ?? '',
label: tile.querySelector('span')?.textContent ?? '',
})),
ctaDirection: destination ? getComputedStyle(destination).direction : null,
ctaLineTexts: ctaLines.map((line) => (line.textContent ?? '').replace(/\s+/g, ' ').trim()),
ctaLineHorizontalOverflows: ctaLines.map((line) =>
Math.max(0, line.scrollWidth - line.clientWidth),
),
ctaLineVerticalOverflows: ctaLines.map((line) =>
Math.max(0, line.scrollHeight - line.clientHeight),
),
destinationFragmentHorizontalOverflows:
destination && destinationLineBox
? Array.from(destination.getClientRects()).map((fragment) =>
Math.max(
0,
destinationLineBox.left - fragment.left,
fragment.right - destinationLineBox.right,
),
)
: [],
interactiveCtaCount: document.querySelectorAll('.cover-cta a, .cover-cta button').length,
gainCount: document.querySelectorAll('.cover-gain-text').length,
peopleCount: document.querySelectorAll('.cover-person').length,
descriptionCount: document.querySelectorAll('.cover-description').length,
titleDirection: getComputedStyle(title).direction,
chromeDirection: getComputedStyle(chrome).direction,
panelWidth: panelBox.width,
panelPaddingY: parseFloat(panelStyle.paddingTop),
panelContentWidth:
panelBox.width -
2 * parseFloat(panelStyle.borderLeftWidth) -
2 * parseFloat(panelStyle.paddingLeft),
};
});
}
interface Scenario {
name: string;
kind: 'quiz' | 'pbl';
frame: keyof typeof FRAMES;
locale: Locale;
content: CoverContent;
cta: VideoExportCta;
titleDirection: 'ltr' | 'rtl';
chromeDirection: 'ltr' | 'rtl';
burnInSubtitles?: boolean;
expectedGainCount?: number;
expectedDestination?: string;
}
const SCENARIOS: Scenario[] = [
{
name: 'Quiz default CTA',
kind: 'quiz',
frame: '720p',
locale: 'en-US',
content: CONTENT.english,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: 'Quiz Arabic authored text in English UI with www override',
kind: 'quiz',
frame: '480p',
locale: 'en-US',
content: CONTENT.arabic,
cta: WWW_CTA,
titleDirection: 'rtl',
chromeDirection: 'ltr',
},
{
name: 'Quiz English authored text in real Arabic UI',
kind: 'quiz',
frame: '720p',
locale: 'ar-SA',
content: CONTENT.english,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'rtl',
},
{
name: 'Quiz real Arabic at compact resolution',
kind: 'quiz',
frame: '480p',
locale: 'ar-SA',
content: CONTENT.arabic,
cta: WWW_CTA,
titleDirection: 'rtl',
chromeDirection: 'rtl',
},
...(['720p', '480p'] as const).map(
(frame): Scenario => ({
name: `Quiz burn-in subtitles at ${frame}`,
kind: 'quiz',
frame,
locale: 'en-US',
content: CONTENT.english,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
burnInSubtitles: true,
}),
),
{
name: 'PBL retains five gains',
kind: 'pbl',
frame: '720p',
locale: 'en-US',
content: CONTENT.compactFive,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
expectedGainCount: 5,
},
...(['720p', '480p'] as const).flatMap((frame): Scenario[] => [
{
name: `Quiz accepted 96-character wide ASCII CTA at ${frame}`,
kind: 'quiz',
frame,
locale: 'en-US',
content: CONTENT.english,
cta: MAX_WIDTH_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `Quiz maximum Unicode CTA in real Arabic UI at ${frame}`,
kind: 'quiz',
frame,
locale: 'ar-SA',
content: CONTENT.arabic,
cta: MAX_UNICODE_CTA,
expectedDestination: MAX_UNICODE_CTA_RAW,
titleDirection: 'rtl',
chromeDirection: 'rtl',
},
{
name: `PBL accepted 96-character wide ASCII CTA at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.english,
cta: MAX_WIDTH_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL maximum Unicode CTA in real Arabic UI at ${frame}`,
kind: 'pbl',
frame,
locale: 'ar-SA',
content: CONTENT.arabic,
cta: MAX_UNICODE_CTA,
expectedDestination: MAX_UNICODE_CTA_RAW,
titleDirection: 'rtl',
chromeDirection: 'rtl',
},
{
name: `PBL CJK at ${frame}`,
kind: 'pbl',
frame,
locale: 'zh-CN',
content: CONTENT.cjk,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL repeated mmmm at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.repeatedM,
cta: WWW_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL repeated wwww at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.repeatedW,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL uppercase W at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.uppercaseW,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL uppercase M at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.uppercaseM,
cta: WWW_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL digits at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.digits,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL emoji at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.emoji,
cta: WWW_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
},
{
name: `PBL Arabic authored text in English UI at ${frame}`,
kind: 'pbl',
frame,
locale: 'en-US',
content: CONTENT.arabic,
cta: WWW_CTA,
titleDirection: 'rtl',
chromeDirection: 'ltr',
},
{
name: `PBL English authored text in real Arabic UI at ${frame}`,
kind: 'pbl',
frame,
locale: 'ar-SA',
content: CONTENT.english,
cta: DEFAULT_CTA,
titleDirection: 'ltr',
chromeDirection: 'rtl',
},
{
name: `PBL burn-in subtitles at ${frame}`,
kind: 'pbl',
frame,
locale: 'zh-CN',
content: CONTENT.cjk,
cta: WWW_CTA,
titleDirection: 'ltr',
chromeDirection: 'ltr',
burnInSubtitles: true,
},
]),
];
describe('Quiz/PBL cover cards in a real browser', () => {
let browser: Browser | undefined;
let page: Page | undefined;
beforeAll(async () => {
try {
browser = await chromium.launch();
page = await browser.newPage();
await page.route(HARNESS_URL, (route) =>
route.fulfill({
body: '<!doctype html><html><body></body></html>',
contentType: 'text/html',
}),
);
await page.route('**/gsap.min.js', (route) =>
route.fulfill({ body: GSAP, contentType: 'text/javascript' }),
);
await page.route('**/*.woff2', (route) => {
const filename = new URL(route.request().url()).pathname.split('/').at(-1)!;
route.fulfill({
body: readFileSync(join(process.cwd(), 'public/vendor/video-export/fonts', filename)),
contentType: 'font/woff2',
});
});
} catch (error) {
if (REQUIRED) throw error;
}
}, 60_000);
afterAll(async () => {
await browser?.close();
});
const check = (name: string, run: (page: Page) => Promise<void>) =>
it(name, async (ctx) => {
if (!page) return ctx.skip();
await run(page);
});
check('scopes Arabic direction below the document and composition roots', async (page) => {
const html = coverHtml(CONTENT.arabic, FRAMES['720p'], {
kind: 'pbl',
locale: 'ar-SA',
cta: WWW_CTA,
});
await loadEmittedHtml(page, html);
expect(
await page.evaluate(() => ({
html: document.documentElement.getAttribute('dir'),
body: document.body.getAttribute('dir'),
composition: document
.querySelector<HTMLElement>('[data-composition-id]')!
.getAttribute('dir'),
frame: document.querySelector<HTMLElement>('.cover-card')!.getAttribute('dir'),
panel: document.querySelector<HTMLElement>('.cover-panel')!.getAttribute('dir'),
title: document.querySelector<HTMLElement>('.cover-title')!.getAttribute('dir'),
destination: document.querySelector<HTMLElement>('.cover-cta bdi')!.getAttribute('dir'),
chromeDirection: getComputedStyle(document.querySelector<HTMLElement>('.cover-eyebrow')!)
.direction,
})),
).toEqual({
html: null,
body: null,
composition: null,
frame: null,
panel: 'rtl',
title: 'auto',
destination: 'ltr',
chromeDirection: 'rtl',
});
});
for (const scenario of SCENARIOS) {
check(`fits ${scenario.name}`, async (page) => {
const frame = FRAMES[scenario.frame];
const result = await measure(page, coverHtml(scenario.content, frame, scenario), frame);
const labels = getVideoExportCoverLabels(scenario.locale);
const expectedStats =
scenario.kind === 'quiz'
? [
{ count: '2', label: labels.questions },
{ count: '5', label: labels.points },
]
: [
{ count: '2', label: labels.stages },
{ count: '3', label: labels.tasks },
];
const expectedPrompt = scenario.kind === 'quiz' ? labels.quizCtaPrompt : labels.pblCtaPrompt;
const expectedDestination = scenario.expectedDestination ?? scenario.cta.destination;
expect(
result.overflow,
JSON.stringify({
scenario: scenario.name,
destination: scenario.cta.destination,
result,
}),
).toBeLessThanOrEqual(0);
expect(result.panelTop).toBeGreaterThanOrEqual(0);
expect(result.panelBottom).toBeLessThanOrEqual(result.stageHeight);
expect(result.statTiles).toEqual(expectedStats);
expect(result.ctaLineTexts).toEqual([
expectedPrompt,
`${labels.ctaVisit} ${expectedDestination}`,
]);
expect(result.ctaDirection).toBe('ltr');
expect(result.ctaLineHorizontalOverflows).toEqual([0, 0]);
expect(result.ctaLineVerticalOverflows).toEqual([0, 0]);
expect(result.destinationFragmentHorizontalOverflows.length).toBeGreaterThan(0);
expect(
result.destinationFragmentHorizontalOverflows.every((overflow) => overflow === 0),
).toBe(true);
expect(result.interactiveCtaCount).toBe(0);
expect(result.titleDirection).toBe(scenario.titleDirection);
expect(result.chromeDirection).toBe(scenario.chromeDirection);
if (scenario.expectedGainCount !== undefined) {
expect(result.gainCount).toBe(scenario.expectedGainCount);
}
if (scenario.burnInSubtitles) {
expect(result.captionTop).not.toBeNull();
expect(result.panelBottom).toBeLessThanOrEqual(result.captionTop!);
}
});
}
check('uses the same PBL design box in the planner and Chromium', async (page) => {
const frame = FRAMES['720p'];
const result = await measure(
page,
coverHtml(CONTENT.compactFive, frame, {
kind: 'pbl',
locale: 'en-US',
cta: DEFAULT_CTA,
}),
frame,
);
expect(result.panelWidth).toBeCloseTo(PBL_PANEL_DESIGN_BOX.width, 1);
expect(result.panelPaddingY).toBeCloseTo(PBL_PANEL_DESIGN_BOX.paddingY, 1);
expect(result.panelContentWidth).toBeCloseTo(PBL_PANEL_DESIGN_BOX.contentWidth, 1);
});
check('retains a people row at its conservative height threshold', async (page) => {
const frame = { width: 1280, height: 486 };
const locale = 'en-US';
const labels = getVideoExportCoverLabels(locale);
const result = await measure(
page,
coverHtml({ title: 'People threshold', description: '', gains: [] }, frame, {
kind: 'pbl',
locale,
cta: DEFAULT_CTA,
}),
frame,
);
expect(result.peopleCount).toBe(2);
expect(result.gainCount).toBe(0);
expect(result.descriptionCount).toBe(0);
expect(result.overflow).toBeLessThanOrEqual(0);
expect(result.panelBottom).toBeLessThanOrEqual(result.stageHeight);
expect(result.statTiles).toEqual([
{ count: '2', label: labels.stages },
{ count: '3', label: labels.tasks },
]);
expect(result.ctaLineTexts).toEqual([
labels.pblCtaPrompt,
`${labels.ctaVisit} ${DEFAULT_CTA.destination}`,
]);
});
for (const [name, frame] of Object.entries(QUIZ_LIST_FRAMES)) {
check(
`measures and reaches the real Quiz list bottom consistently at ${name}`,
async (page) => {
const initial = quizListHtml(frame, LONG_QUIZ_QUESTIONS, {
contentHeightPx: frame.height * 2,
viewportHeightPx: frame.height / 2,
});
await page.setViewportSize(frame);
await loadEmittedHtml(page, initial.html);
const reads = await page.evaluate(async () => {
const values: Array<{ contentHeightPx: number; viewportHeightPx: number }> = [];
for (let index = 0; index < 3; index += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const content = document.querySelector<HTMLElement>('.quiz-list-content')!;
const viewport = document.querySelector<HTMLElement>('.quiz-list-viewport')!;
values.push({
contentHeightPx: Math.ceil(content.scrollHeight),
viewportHeightPx: Math.ceil(viewport.clientHeight),
});
}
return values;
});
expect(reads[1]).toEqual(reads[0]);
expect(reads[2]).toEqual(reads[0]);
expect(reads[0].contentHeightPx).toBeGreaterThan(reads[0].viewportHeightPx);
const resolved = quizListHtml(frame, LONG_QUIZ_QUESTIONS, reads[0]);
await loadEmittedHtml(page, resolved.html);
const geometry = await page.evaluate((distance) => {
const viewport = document.querySelector<HTMLElement>('.quiz-list-viewport')!;
const content = document.querySelector<HTMLElement>('.quiz-list-content')!;
const first = document.querySelector<HTMLElement>('.quiz-list-question')!;
const cards = document.querySelectorAll<HTMLElement>('.quiz-list-question');
const last = cards[cards.length - 1]!;
const viewportBox = viewport.getBoundingClientRect();
const firstTop = first.getBoundingClientRect().top;
const fontReady = document.fonts.check('16px KaTeX_Math');
const math = document.querySelector<HTMLElement>('.katex .mathnormal');
content.style.transform = `translateY(-${distance}px)`;
const lastBottom = last.getBoundingClientRect().bottom;
return {
firstOffsetPx: firstTop - viewportBox.top,
lastOffsetPx: lastBottom - viewportBox.bottom,
fontReady,
mathFontFamily: math ? getComputedStyle(math).fontFamily : '',
externalFontUrls: Array.from(document.styleSheets)
.flatMap((sheet) => {
try {
return Array.from(sheet.cssRules, (rule) => rule.cssText);
} catch {
return [];
}
})
.filter((rule) => /url\(["']?https?:\/\//.test(rule)),
};
}, resolved.scrollDistancePx);
expect(geometry.firstOffsetPx).toBeCloseTo(0, 0);
expect(Math.abs(geometry.lastOffsetPx)).toBeLessThanOrEqual(1);
expect(geometry.fontReady).toBe(true);
expect(geometry.mathFontFamily).toContain('KaTeX_Math');
expect(geometry.externalFontUrls).toEqual([]);
},
);
}
for (const blockedStep of ['fonts', 'animation-frame'] as const) {
check(`bounds Quiz measurement when ${blockedStep} never settles`, async (page) => {
const frame = QUIZ_LIST_FRAMES['720p'];
const fixture = quizScrollScene();
const surface = createQuizQuestionListMeasurementSurface({
content: {
title: fixture.title,
questions: prepareQuizQuestionList(fixture),
},
width: frame.width,
height: frame.height,
locale: 'en-US',
labels: getVideoExportCoverLabels('en-US'),
});
await page.setViewportSize(frame);
await page.goto(HARNESS_URL, { waitUntil: 'load' });
const result = await page.evaluate(
async ({ source, input, blocked }) => {
const measure = (0, eval)(`(${source})`) as (
value: typeof input,
timeoutMs?: number,
) => Promise<unknown>;
const originalRaf = window.requestAnimationFrame;
if (blocked === 'fonts') {
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: new Promise<void>(() => {}) },
});
} else {
window.requestAnimationFrame = () => 0;
}
const outcome = await Promise.race([
measure(input, 40).then((value) => ({ settled: true, value })),
new Promise<{ settled: false; value: null }>((resolve) =>
setTimeout(() => resolve({ settled: false, value: null }), 250),
),
]);
const remainingHosts = document.querySelectorAll('#quiz-layout-measurement').length;
window.requestAnimationFrame = originalRaf;
if (blocked === 'fonts') {
delete (document as unknown as { fonts?: FontFaceSet }).fonts;
}
document.querySelector('#quiz-layout-measurement')?.remove();
return { ...outcome, remainingHosts };
},
{ source: measureQuizQuestionList.toString(), input: surface, blocked: blockedStep },
);
expect(result).toEqual({ settled: true, value: null, remainingHosts: 0 });
const degraded = compileVideoTimeline(
{ stage: { id: 'stage', name: 'deadline fallback' }, scenes: [fixture] },
{
timing: NO_PROBE,
assets: NO_ASSETS,
quizLayout: { measureQuestionList: () => result.value as null },
},
);
expect(degraded.scenes[0].visuals).not.toContainEqual(
expect.objectContaining({ kind: 'quiz-question-list' }),
);
expect(degraded.diagnostics).toContainEqual(
expect.objectContaining({ code: 'quiz-layout-unavailable', sceneId: fixture.id }),
);
});
}
check('runs the production Quiz measurer and emitted timeline end to end', async (page) => {
const frame = QUIZ_LIST_FRAMES['720p'];
const initial = quizListHtml(frame, LONG_QUIZ_QUESTIONS, {
contentHeightPx: frame.height * 2,
viewportHeightPx: frame.height / 2,
});
await page.setViewportSize(frame);
await loadEmittedHtml(page, initial.html);
const fixture = quizScrollScene();
const surface = createQuizQuestionListMeasurementSurface({
content: {
title: fixture.title,
questions: prepareQuizQuestionList(fixture),
},
width: frame.width,
height: frame.height,
locale: 'en-US',
labels: getVideoExportCoverLabels('en-US'),
});
const measureSource = measureQuizQuestionList.toString();
const measured = await page.evaluate(
async ({ source, input }) => {
try {
const measure = (0, eval)(`(${source})`) as (value: typeof input) => Promise<{
contentHeightPx: number;
viewportHeightPx: number;
frameHeightPx: number;
} | null>;
return {
value: await measure(input),
error: null,
remainingHosts: document.querySelectorAll('#quiz-layout-measurement').length,
};
} catch (error) {
return {
value: null,
error: error instanceof Error ? error.message : String(error),
remainingHosts: document.querySelectorAll('#quiz-layout-measurement').length,
};
}
},
{ source: measureSource, input: surface },
);
expect(measured.error).toBeNull();
expect(measured.remainingHosts).toBe(0);
expect(measured.value).toEqual(QUIZ_SCROLL_LAYOUT_720P);
expect(surface.css).not.toMatch(/url\(["']?https?:\/\//);
expect(
await page.evaluate(() => document.fonts.check('16px "OpenMAIC Noto Sans SC"', '中文')),
).toBe(true);
const cjkCoverage = await page.evaluate(async () => {
await Promise.all([
document.fonts.load('40px "OpenMAIC Noto Sans SC"', '漢字あア'),
document.fonts.load('40px "OpenMAIC Noto Sans KR"', '한글'),
]);
const signature = (text: string, font: string): number => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const context = canvas.getContext('2d')!;
context.font = `40px ${font}`;
context.fillText(text, 0, 44);
let hash = 2_166_136_261;
for (const value of context.getImageData(0, 0, 256, 64).data) {
hash ^= value;
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
};
const covered = (text: string, family: string): boolean =>
signature(text, `"${family}", monospace`) !== signature(text, 'monospace');
return {
han: covered('漢字', 'OpenMAIC Noto Sans SC'),
kana: covered('あア', 'OpenMAIC Noto Sans SC'),
hangul: covered('한글', 'OpenMAIC Noto Sans KR'),
};
});
expect(cjkCoverage).toEqual({ han: true, kana: true, hangul: true });
const resolved = quizListHtml(frame, LONG_QUIZ_QUESTIONS, measured.value!);
await loadEmittedHtml(page, resolved.html);
const checkpoints = await page.evaluate((timing) => {
const timeline = (
window as typeof window & {
__timelines: Record<string, { time(value: number, suppressEvents?: boolean): unknown }>;
}
).__timelines.openmaic;
const cover = document.querySelector<HTMLElement>('#scene-1-visual-1')!;
const list = document.querySelector<HTMLElement>('#scene-1-visual-2')!;
const content = document.querySelector<HTMLElement>('.quiz-list-content')!;
const viewport = document.querySelector<HTMLElement>('.quiz-list-viewport')!;
const first = document.querySelector<HTMLElement>('.quiz-list-question')!;
const cards = document.querySelectorAll<HTMLElement>('.quiz-list-question');
const last = cards[cards.length - 1]!;
const read = (timeMs: number) => {
timeline.time(timeMs / 1000, false);
const viewportBox = viewport.getBoundingClientRect();
return {
coverOpacity: Number(getComputedStyle(cover).opacity),
listOpacity: Number(getComputedStyle(list).opacity),
firstOffsetPx: first.getBoundingClientRect().top - viewportBox.top,
lastOffsetPx: last.getBoundingClientRect().bottom - viewportBox.bottom,
transform: getComputedStyle(content).transform,
};
};
return {
transitionMid: read((timing.transitionStartMs + timing.transitionEndMs) / 2),
topHoldEnd: read(timing.topHoldEndMs),
scrollQuarter: read(timing.topHoldEndMs + (timing.scrollEndMs - timing.topHoldEndMs) / 4),
scrollEnd: read(timing.scrollEndMs),
bottomHoldEnd: read(timing.bottomHoldEndMs),
};
}, resolved.timing);
expect(checkpoints.transitionMid.coverOpacity).toBeCloseTo(0.5, 1);
expect(checkpoints.transitionMid.listOpacity).toBeCloseTo(0.5, 1);
expect(checkpoints.topHoldEnd.firstOffsetPx).toBeCloseTo(0, 0);
expect(checkpoints.scrollQuarter.firstOffsetPx).toBeCloseTo(-resolved.scrollDistancePx / 4, 0);
expect(Math.abs(checkpoints.scrollEnd.lastOffsetPx)).toBeLessThanOrEqual(1);
expect(checkpoints.bottomHoldEnd.lastOffsetPx).toBeCloseTo(
checkpoints.scrollEnd.lastOffsetPx,
1,
);
expect(checkpoints.bottomHoldEnd.transform).toBe(checkpoints.scrollEnd.transform);
});
check('keeps a short Quiz list stationary when it fits', async (page) => {
const frame = QUIZ_LIST_FRAMES['720p'];
const shortQuestions = [
{
id: 'short',
type: 'short_answer',
question: 'Explain $x=2$.',
},
];
const first = quizListHtml(frame, shortQuestions, {
contentHeightPx: 100,
viewportHeightPx: 100,
});
await page.setViewportSize(frame);
await loadEmittedHtml(page, first.html);
const measured = await page.evaluate(() => {
const content = document.querySelector<HTMLElement>('.quiz-list-content')!;
const viewport = document.querySelector<HTMLElement>('.quiz-list-viewport')!;
return {
contentHeightPx: Math.ceil(content.scrollHeight),
viewportHeightPx: Math.ceil(viewport.clientHeight),
};
});
const resolved = quizListHtml(frame, shortQuestions, measured);
expect(resolved.scrollDistancePx).toBe(0);
expect(resolved.html).not.toMatch(/tl\.to\('#scene-1-visual-2-content'/);
});
});