/** * 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; 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 { 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; }; 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 { await page.setViewportSize(frame); await loadEmittedHtml(page, html); return page.evaluate(() => { const stage = document.querySelector('[data-composition-id]')!; const panel = document.querySelector('.cover-panel')!; const title = document.querySelector('.cover-title')!; const chrome = document.querySelector('.cover-eyebrow')!; const destination = document.querySelector('.cover-cta bdi'); const cue = document.querySelector('#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('.cover-cta-line')); const destinationLine = destination?.closest('.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('.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: '', 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) => 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('[data-composition-id]')! .getAttribute('dir'), frame: document.querySelector('.cover-card')!.getAttribute('dir'), panel: document.querySelector('.cover-panel')!.getAttribute('dir'), title: document.querySelector('.cover-title')!.getAttribute('dir'), destination: document.querySelector('.cover-cta bdi')!.getAttribute('dir'), chromeDirection: getComputedStyle(document.querySelector('.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((resolve) => requestAnimationFrame(() => resolve())); const content = document.querySelector('.quiz-list-content')!; const viewport = document.querySelector('.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('.quiz-list-viewport')!; const content = document.querySelector('.quiz-list-content')!; const first = document.querySelector('.quiz-list-question')!; const cards = document.querySelectorAll('.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('.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; const originalRaf = window.requestAnimationFrame; if (blocked === 'fonts') { Object.defineProperty(document, 'fonts', { configurable: true, value: { ready: new Promise(() => {}) }, }); } 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; } ).__timelines.openmaic; const cover = document.querySelector('#scene-1-visual-1')!; const list = document.querySelector('#scene-1-visual-2')!; const content = document.querySelector('.quiz-list-content')!; const viewport = document.querySelector('.quiz-list-viewport')!; const first = document.querySelector('.quiz-list-question')!; const cards = document.querySelectorAll('.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('.quiz-list-content')!; const viewport = document.querySelector('.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'/); }); });