// 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(/