// The teaser: its validation, its title, the deck hiding over it, the page it // draws, its cue times, and the render cache key that changed words change. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { createHash } from "node:crypto"; import { CARD_TYPES, deckChoreography, deckSchedule, deckText, estimatedDuration, hidesDeck, resolveDeck, TEASER_BEAT, TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes, teaserTitle, validateTeaser, validateTeasers, } from "./deck.mjs"; import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs"; import { composeChrome } from "./compose-chrome.mjs"; const FERRET = Object.freeze({ type: "teaser", id: "fin", seconds: 7, lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"], tail: "?", }); const RENDER = { width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }, }; test("a valid teaser has nothing to say; every bad shape is a sentence", () => { assert.deepEqual(validateTeaser(FERRET), []); assert.deepEqual(validateTeaser({ ...FERRET, tail: undefined, hits: false }), []); const bad = (patch) => validateTeaser({ ...FERRET, ...patch }).join(" | "); assert.match(bad({ lines: [] }), /lines must be a list of 1 to 8/); assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }), /1 to 8/); // Six ROWS do not fit; six lines in five rows do. assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"], seconds: undefined }), /make 6 rows, and at most 5 fit/); assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined, lines: ["a", "b", "c", { text: "c2", replace: true }, "d", "e"] }), []); assert.match(bad({ lines: ["ok", ""] }), /lines\[1\] must be words/); assert.match(bad({ lines: ["two\nlines"] }), /one line/); assert.match(bad({ lines: ["x".repeat(81)] }), /81 characters/); assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/); assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/); assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/); assert.match(bad({ lines: [42] }), /string or \{ text, break, replace, role, hold, together, lead \}/); assert.match(bad({ lines: ["a", { text: "b", lead: "x".repeat(67) }] }), /lines\[1\]\.lead is 67 characters, and at most 66 fit/); assert.match(bad({ lines: ["a", { text: "b", lead: "" }] }), /lines\[1\]\.lead must be words/); assert.match(bad({ lines: ["a", { text: "b", together: true }] }), /lines\[1\]\.together pops the second tier with its line, and it has no break/); assert.match(bad({ lines: ["a", { text: "b c", break: "c", together: 1 }] }), /lines\[1\]\.together must be true or false/); assert.match(bad({ lines: [{ text: "First", replace: true }] }), /lines\[0\]\.replace: the first line has no line before it/); assert.match(bad({ lines: ["a", { text: "b", replace: "yes" }] }), /lines\[1\]\.replace must be true or false/); assert.match(bad({ lines: ["a", { text: "b", role: "huge" }] }), /lines\[1\]\.role must be one of overline, title, kicker/); assert.match(bad({ lines: ["a", { text: "b", hold: 5 }] }), /lines\[1\]\.hold must be from 0 to 4 seconds/); assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/); assert.match(bad({ seconds: "7" }), /seconds must be from 3 to 20, or absent/); assert.match(bad({ beat: 0.3 }), /beat must be from 0\.4 to 2\.5 seconds/); assert.match(bad({ beat: 2.6 }), /beat must be from 0\.4 to 2\.5/); assert.match(bad({ beat: "slow" }), /beat must be/); assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []); assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined }), []); assert.match(bad({ seconds: 21 }), /from 3 to 20/); assert.match(bad({ tail: "" }), /tail must be a short string/); assert.match(bad({ tail: "?????????" }), /tail is 9 characters/); assert.match(bad({ hits: "yes" }), /hits must be true or false/); // tailWait: seconds from the last hit to the tail, only with a tail. assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined, tailWait: 2 }), []); assert.match(bad({ tailWait: 2 }), /seconds is 7, and at a beat of 0\.7s its lines need 7\.4s/); assert.match(bad({ tailWait: 0.2 }), /tailWait must be from 0\.3 to 6 seconds, or absent/); assert.match(bad({ tailWait: 6.5 }), /tailWait must be from 0\.3 to 6/); assert.match(bad({ tailWait: "2" }), /tailWait must be/); assert.match(bad({ tailWait: 2, tail: undefined }), /tailWait is the wait before the tail, and there is no tail/); // What fits the frame at each role's floor (TEASER_LIMITS.fit): an // 80-character title spilled past both edges. const T80 = "The Largest Ferret Rescue Operation Ever Attempted Anywhere in the United States"; assert.match(bad({ lines: ["Pirate Software", T80, "February 2027"] }), /lines\[1\] is 80 characters, and at most 34 fit the frame as the title -- shorten it, or set its end as a break/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34), "February 2027"] }), []); // The tail counts on the row that carries it -- the last, or its second // tier -- TWICE: the row is centred by its own words and the tail hangs off // the right, so it needs the tail and its gap on both sides of the centre. assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 38 characters with the tail/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "Title", "y".repeat(52)] }), []); assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(53)] }), /lines\[2\] is 57 characters with the tail, and at most 56 fit the frame as the kicker/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34)], tail: undefined }), []); assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 60 characters with the tail.*as the kicker/); assert.match(bad({ lines: ["o".repeat(65), "Title", "Date"] }), /lines\[0\] is 65 characters.*as the overline/); // A break: the head in the line's role, the break in the second tier. assert.deepEqual(validateTeaser({ ...FERRET, lines: ["P", { text: T80, break: "Operation Ever Attempted Anywhere in the United States" }, "D"] }), []); assert.match(bad({ lines: ["P", { text: T80, break: "in the United States" }, "D"] }), /lines\[1\] before its break is 59 characters, and at most 34 fit the frame as the title$/m); assert.match(bad({ lines: ["P", { text: `A ${"s".repeat(66)}`, break: "s".repeat(66) }] }), /lines\[1\]\.break is 70 characters with the tail, and at most 66 fit the frame as the second tier/); assert.match(bad({ id: "../x" }), /id must be letters/); // The manifest's validator names where. const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] }); assert.equal(errs.length, 1); assert.match(errs[0], /^timeline\[1\] \(fin\)\.seconds/); }); test("lines: roles by position, the break is the second tier, the title joins them", () => { const l = teaserLines(FERRET); assert.deepEqual(l.map((x) => x.role), ["overline", "title", "kicker"]); assert.equal(l[1].head, "The Largest Ferret Rescue"); assert.equal(l[1].sub, "in the United States"); assert.equal(l[0].sub, null); assert.deepEqual(teaserLines({ lines: ["A", "B"] }).map((x) => x.role), ["overline", "title"]); assert.deepEqual(teaserLines({ lines: ["A"] }).map((x) => x.role), ["title"]); assert.deepEqual(teaserLines({ lines: ["A", "B", "C", "D"] }).map((x) => x.role), ["overline", "title", "title", "kicker"]); assert.equal( teaserTitle(FERRET), "Pirate Software — The Largest Ferret Rescue in the United States — February 2027 ?", ); assert.equal(teaserTitle({ ...FERRET, tail: undefined }).endsWith("February 2027"), true); }); test("the chapter is the teaser's title, with or without the deck; an authored chapter wins", async () => { assert.equal(await chapterTitle(FERRET, 17, {}), teaserTitle(FERRET)); assert.equal(await chapterTitle(FERRET, 17, {}, { deck: true }), teaserTitle(FERRET)); assert.equal(await chapterTitle({ ...FERRET, chapter: "Next season" }, 17, {}, { deck: true }), "Next season"); }); test("the deck slides away over a teaser whatever overCards says; no pip, no QR", () => { assert.ok(CARD_TYPES.includes("teaser")); for (const overCards of ["hide", "show"]) { const deck = resolveDeck({ chrome: { engine: "hyperframes", layout: "deck", deck: { overCards } } }); assert.equal(hidesDeck(FERRET, deck), true, overCards); assert.equal(hidesDeck({ type: "card" }, deck), overCards === "hide"); } const render = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; const clip = { type: "clip", id: "c20", video: "v", start: 10, end: 20, citeUrl: "https://example.org/c20" }; const sched = deckSchedule({ entries: [clip, FERRET], durs: [10, 7], D: 0.5, render }); const fin = sched.segments[1]; assert.equal(fin.hideDeck, true); assert.equal(fin.qrUrl, null); assert.equal(fin.title, teaserTitle(FERRET)); assert.equal(fin.subtitle, ""); // Into the teaser the deck hides (a visibility change), no text handover. const ch = deckChoreography(sched, render); assert.equal(ch.handovers.length, 0); assert.deepEqual(ch.visibility.map((v) => [v.i, v.hide]), [[1, true]]); assert.deepEqual(deckText(FERRET, null, {}, resolveDeck(render), false), { title: teaserTitle(FERRET), subtitle: "" }); }); test("the cues: one per pop at the shared times, top to bottom, every from stated", () => { const lines = teaserLines(FERRET); const { cues, init, beats } = teaserCues({ lines, tail: "?", seconds: 7 }); const m = TEASER_MOTION; // The times are the motion's own: nothing is ever compressed. assert.deepEqual(beats.lines.map((b) => b.at), [m.first, m.first + m.gap, m.first + m.gap + m.sub + m.gap]); assert.equal(beats.lines[1].subAt, m.first + m.gap + m.sub); // About 0.6–0.8 s apart, in order. const ats = beats.lines.map((b) => b.at); for (let i = 1; i < ats.length; i += 1) assert.ok(ats[i] - ats[i - 1] >= 0.6 && ats[i] - ats[i - 1] <= 1.0001); // The tail starts after the date has settled and is in before the end fade's hold. assert.ok(beats.tailAt >= beats.lines[2].impact + m.settle - 1e-9); assert.ok(beats.tailAt + beats.tailDur <= 7 - m.endRoom + 1e-9); // The slam lands on the impact, and the flash is centred on it. lines.forEach((_, i) => { const slam = cues.find((c) => c.why === `line ${i} slam`); assert.equal(Math.round((slam.at + slam.dur) * 1e4) / 1e4, beats.lines[i].impact); const flash = cues.find((c) => c.why === `line ${i} flash`); assert.equal(Math.round((flash.at + flash.dur / 2) * 1e4) / 1e4, beats.lines[i].impact); }); // Every cue's from is stated, and is the state the element was left in. const state = JSON.parse(JSON.stringify(init)); for (const c of cues) { for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k][p], `${c.k}.${p} at ${c.at}`); Object.assign(state[c.k], c.to); } // Seek-safe: no cue on an element starts before the one before it has ended. const free = new Map(); for (const c of cues) { assert.ok(c.at >= (free.get(c.k) ?? 0) - 1e-9, `${c.k} at ${c.at}`); free.set(c.k, c.at + c.dur); } // The end state: every line and the tail fully shown. for (let i = 0; i < lines.length; i += 1) assert.equal(state[`l${i}.o`].autoAlpha, 1); assert.equal(state.tail.autoAlpha, 1); assert.equal(state["l1.sub"].autoAlpha, 1); }); test("nothing is squeezed: a card too short for its beats is refused with the length they need", () => { const FIVE = { ...FERRET, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }; const t = teaserTimes(teaserLines(FIVE), "?"); assert.equal(t.scale, 1); // 0.55 + four beats + the second tier (0.3) + the tail (0.8 after, 1.7 in) + 1.2 still. assert.equal(t.need, 7.35); assert.match(validateTeaser({ ...FIVE, seconds: 3 }).join(" | "), /seconds is 3, and at a beat of 0\.7s its lines need 7\.4s \(the last pop, the tail's fade and 1\.2s still for the end fade\) -- set it to 7\.4 or more, or leave it out for exactly that/); assert.deepEqual(validateTeaser({ ...FIVE, seconds: 7.35 }), []); // The ferret's 7 s holds its beats up to about 0.99 s; past that it is refused, not compressed. assert.deepEqual(validateTeaser({ ...FERRET, beat: 0.9 }), []); assert.match(validateTeaser({ ...FERRET, beat: 1.05 }).join(), /seconds is 7, and at a beat of 1\.05s its lines need 7\.2s/); // Without `seconds`, a card needing more than a teaser may run says so. const broken = { ...FERRET, seconds: undefined, beat: 2.5, lines: ["A b", "C d", "E f", "G h", "I j"].map((t) => ({ text: t, break: t.slice(-1) })) }; assert.match(validateTeaser(broken).join(), /needs 21\.7s at a beat of 2\.5s, and a teaser runs at most 20s -- a shorter beat, or fewer lines/); assert.deepEqual(validateTeaser({ ...broken, beat: 2.2 }), []); // The cues run to their own end, inside the card. const { cues } = teaserCues({ lines: teaserLines(FIVE), tail: "?", seconds: 7.35 }); assert.ok(cues.every((c) => c.at + c.dur <= 7.35 + 1e-6)); }); test("the beat: the gap between pops, the second tier and the tail's wait in proportion", () => { const m = TEASER_MOTION; // No beat, or the default's own, is the motion itself. assert.equal(teaserMotion(undefined), m); assert.equal(teaserMotion(null), m); assert.equal(teaserMotion(m.gap), m); assert.deepEqual([TEASER_BEAT.min, TEASER_BEAT.max], [0.4, 2.5]); const slow = teaserMotion(1.05); // +50 % assert.deepEqual([slow.gap, slow.sub, slow.tailAfter], [1.05, 0.45, 1.2]); // What is not the beat stays put. for (const k of ["first", "hit", "settle", "tailDur", "endRoom", "slam", "under", "blur", "push", "grainHz"]) { assert.equal(slow[k], m[k], k); } assert.ok(Object.isFrozen(slow)); // The hits come from teaserTimes at the beat: each pop a beat after the one // before (or after its second tier), the second tier 3/7 of a beat after its line. const hitsAt = (beat) => teaserHits({ ...FERRET, beat, seconds: undefined }).map((h) => h.at); assert.deepEqual(hitsAt(undefined), [0.75, 1.45, 1.55, 2.45, 3.05]); assert.deepEqual(hitsAt(1.05), [0.75, 1.8, 2.05, 3.3, 4.3]); const times = teaserTimes(teaserLines(FERRET), "?", slow); assert.deepEqual(hitsAt(1.05), [ times.lines[0].impact, times.lines[1].impact, times.lines[1].subAt, times.lines[2].impact, times.tailAt, ]); // The spacing between the pops grows with the beat, and only the beat. const at = (beat) => teaserTimes(teaserLines(FERRET), "?", teaserMotion(beat)).lines.map((l) => l.at); for (const beat of [0.4, 0.9, 1.3, 2.5]) { const [a, b, c] = at(beat); assert.equal(a, m.first); assert.ok(Math.abs(b - a - beat) < 1e-4, `${beat}`); assert.ok(Math.abs(c - b - beat * (1 + m.sub / m.gap)) < 1e-3, `${beat}`); } }); test("the length: `seconds` when set, else what the beats need, up to a tenth and at least 3 s", () => { const free = { ...FERRET, seconds: undefined }; assert.equal(teaserSeconds(FERRET), 7); assert.equal(teaserSeconds({ ...FERRET, beat: 0.9 }), 7); assert.equal(teaserTimes(teaserLines(free), "?").need, 5.95); assert.equal(teaserSeconds(free), 6); assert.equal(teaserSeconds({ ...free, beat: 0.9 }), 6.7); // needs 6.6643 assert.equal(teaserSeconds({ ...free, beat: 1.05 }), 7.2); // needs exactly 7.2 assert.equal(teaserSeconds({ ...free, beat: 1.3 }), 8.1); // One line, no tail: 0.55 + the slam and settle + 1.2 still is 2.45 -- the floor is 3. assert.equal(teaserSeconds({ type: "teaser", id: "x", lines: ["Solo"] }), 3); // The schedule's estimate is the same length. assert.equal(estimatedDuration(free), 6); assert.equal(estimatedDuration(FERRET), 7); // The page is that long, and its tail is in before the end fade's hold. const html = teaserHtml({ ...free, beat: 1.3 }, RENDER); assert.match(html, /data-duration="8\.1"/); const t = teaserTimes(teaserLines(free), teaserTail(free), teaserMotion(1.3)); assert.ok(t.tailAt + t.tailDur <= 8.1 - TEASER_MOTION.endRoom + 1e-9); }); test("a teaser without a beat or a tailWait composes one page, byte for byte", () => { const sha = (s) => createHash("sha256").update(s).digest("hex"); // The ferret teaser's page. Re-pinned on purpose when the tail stopped // counting in its row's centring (it hangs off the right in a zero-width // box, and the fit measures the words plus the tail on both sides): the // year now sits on the frame's centre. Before that, at 07d1fa08, it was // a683c641…; `beat` and `tailWait` left at their defaults keep this one. const BEFORE = "7560538c8eeee0b5f5ad34bd4d67a5aede07b4f312fc2307764cf70b2ca01e90"; assert.equal(sha(teaserHtml(FERRET, RENDER)), BEFORE); assert.equal(sha(teaserHtml({ ...FERRET, beat: TEASER_MOTION.gap }, RENDER)), BEFORE); assert.notEqual(sha(teaserHtml({ ...FERRET, beat: 0.9 }, RENDER)), BEFORE); // A tailWait equal to the beat's own (1.0 s from the kicker's impact at 0.7) is the same page. assert.equal(sha(teaserHtml({ ...FERRET, tailWait: 0.6 }, RENDER)), BEFORE); assert.notEqual(sha(teaserHtml({ ...FERRET, tailWait: 2 }, RENDER)), BEFORE); // The page's data carries the times it always did, and nothing new. const json = JSON.parse(teaserHtml(FERRET, RENDER).match(/ end", break: "end" }, "Feb's 2027"], }; const html = teaserHtml(evil, RENDER); assert.ok(html.includes("<b>Pirate</b> & "Co"")); assert.ok(html.includes("Feb's 2027")); assert.ok(!html.includes("Pirate")); // One script open per script; the words cannot close the data block. assert.equal((html.match(/