// Tests for the thread rail: its settings and their validation (threads.mjs, // through validateChrome), the footage box moving aside for it, its schedule // (dots, runs, outcomes, asides), its cues, its page (chrome-threads.mjs) and // its overlay region. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; import { CARD_REST, OUTCOME_LEAD, planCues, resolveThreads, threadCues, threadOf, threadSchedule, threadsOn, THREADS_LIMITS, validateThreadEntries, validateThreads, } from "./threads.mjs"; import { deckGeometry, deckSchedule, railGap, threadsGeometry, validateChrome } from "./deck.mjs"; import { railLayout, threadsHtml } from "./chrome-threads.mjs"; import { threadsRegion } from "./build-video.mjs"; const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; const LIST = [ { id: "bet", label: "The bet", outcome: { verdict: "CONTRADICTED" } }, { id: "proof", label: "Definitive proof", outcome: { verdict: "NOT_FOUND", label: "Still coming" } }, { id: "aside", label: "An aside" }, ]; const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, threads: { list: LIST } }; const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, chrome: CHROME }; const PLAIN = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; test("the rail is on only with a list of threads", () => { assert.equal(threadsOn(RENDER), true); assert.equal(threadsOn(PLAIN), false); assert.equal(threadsOn({ chrome: { threads: { list: [] } } }), false); }); test("an outcome takes its verdict's colour and the cut's label, or its own label", () => { const t = resolveThreads({ ...RENDER, chrome: { ...CHROME, factcheck: { verdicts: { CONTRADICTED: { label: "Walked back" } } } }, }); assert.equal(t[0].outcome.label, "Walked back"); assert.match(t[0].outcome.color, /^#[0-9a-f]{6}$/i); assert.equal(t[1].outcome.label, "Still coming"); assert.equal(t[2].outcome, null); }); test("validation: the list, ids, labels and outcomes", () => { assert.deepEqual(validateThreads(undefined), []); assert.deepEqual(validateThreads({ list: LIST }), []); assert.match(validateThreads({ list: [] }).join(";"), /1 to 8 threads/); assert.match(validateThreads({ list: LIST, side: "left" }).join(";"), /side is not a threads setting/); const bad = validateThreads({ list: [ { id: "a b", label: "x" }, { id: "dup", label: "x" }, { id: "dup", label: "y".repeat(THREADS_LIMITS.label + 1) }, { id: "o", label: "z", outcome: { verdict: "MAYBE", label: "two\nlines" } }, ], }).join(";"); assert.match(bad, /list\[0\]\.id must be/); assert.match(bad, /dup is listed twice/); assert.match(bad, /list\[2\]\.label must be one line/); assert.match(bad, /outcome\.verdict must be one of/); assert.match(bad, /outcome\.label must be one line/); // validateChrome reads it, and refuses the feed beside it. assert.deepEqual(validateChrome(CHROME, RENDER), []); assert.match( validateChrome({ ...CHROME, deck: { posts: { layout: "feed" } } }, RENDER).join(";"), /threads needs the popup posts/, ); assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.95 } }, RENDER).join(";"), /rail \d+px wide/); }); test("entries: a listed thread, not on a teaser, and every thread used", () => { const timeline = [ { id: "a", type: "clip", thread: "bet" }, { id: "b", type: "clip", thread: "proof" }, { id: "c", type: "clip", thread: "aside" }, ]; assert.deepEqual(validateThreadEntries({ render: RENDER, timeline }), []); const errs = validateThreadEntries({ render: RENDER, timeline: [{ id: "a", type: "clip", thread: "nope" }, { id: "t", type: "teaser", thread: "bet" }], }).join(";"); assert.match(errs, /nope is not in render\.chrome\.threads\.list/); assert.match(errs, /a teaser belongs to no thread/); assert.match(errs, /thread proof has no clip/); assert.equal(threadOf({ thread: "bet" }), "bet"); assert.equal(threadOf({}), null); }); test("the footage moves to the right edge and the rail takes the left", () => { const plain = deckGeometry(PLAIN).footage; const g = deckGeometry(RENDER).footage; assert.equal(g.width, plain.width); assert.equal(g.x, 1920 - g.width - railGap(RENDER)); const rail = threadsGeometry(RENDER); assert.deepEqual([rail.x, rail.y, rail.width, rail.height], [0, g.y, g.x, g.height]); assert.equal(rail.cards.x, railGap(RENDER)); assert.equal(rail.cards.x + rail.cards.width + railGap(RENDER), g.x); const { cards: _c, ...box } = rail; assert.deepEqual(threadsRegion(RENDER, "/f"), { name: "threads", frames: "/f", ...box }); }); const SEGS = [ { id: "s0", start: 0, duration: 10, thread: "bet" }, { id: "s1", start: 9.5, duration: 10, thread: "bet" }, { id: "s2", start: 19, duration: 10, thread: null }, { id: "s3", start: 28.5, duration: 10, thread: "proof" }, { id: "s4", start: 38, duration: 10, thread: "bet" }, { id: "s5", start: 47.5, duration: 6, thread: "aside" }, ]; test("the schedule: dots, runs, outcomes on the last clip, asides for posts", () => { const moves = [{ segment: "s3", at: 30, segmentAt: 1.5, seconds: 0.6 }]; const s = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves }); const bet = s.threads[0]; assert.deepEqual(bet.clips.map((c) => [c.segment, c.at]), [["s0", 0], ["s1", 9.75], ["s4", 38.25]]); // Stamped OUTCOME_LEAD (and the dissolve) before its last clip hands over. assert.equal(bet.closeAt, 47.5 - 0.5 - OUTCOME_LEAD); // A thread without an outcome is never closed. assert.equal(s.threads[2].closeAt, undefined); assert.deepEqual(s.runs, [ { thread: "bet", from: 0, to: 19 }, { thread: "proof", from: 28.5, to: 38 }, { thread: "bet", from: 38, to: 47.5 }, { thread: "aside", from: 47.5, to: 53.5 }, ]); assert.deepEqual(s.asides, [{ from: 30, to: 38 }]); // An outcome that would land while the rail is aside lands as it comes back. const hid = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s4", at: 40 }], }); assert.equal(hid.threads[0].closeAt, 47.5 + 0.4 + 0.1); // A clip too short for the lead is stamped half a second in, never before. const short = threadSchedule({ segments: [{ id: "x", start: 0, duration: 1.2, thread: "proof" }], D: 0.5, total: 1.2, render: RENDER, }); assert.equal(short.threads[1].closeAt, 0.5); }); test("the cues state every from, light and dim each run, and stamp each outcome once", () => { const sched = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s3", at: 30 }] }); const { init, cues } = threadCues(sched); for (const c of cues) { for (const k of Object.keys(c.to)) assert.notEqual(c.from[k], undefined, `${c.k} ${k} has a from`); } // Its thread opens the cut, so its card comes up lit; it lights again for its second run. const lit = cues.filter((c) => c.k === "c0" && c.to.opacity === 1).map((c) => c.at); assert.equal(lit[0], 0); assert.equal(lit[lit.length - 1], 38); assert.equal(cues.find((c) => c.k === "c1").to.opacity, CARD_REST.ahead); const rests = cues.filter((c) => c.k === "c0" && c.to.opacity === CARD_REST.done); assert.equal(rests.length, 2); assert.equal(cues.filter((c) => c.k === "o0" && c.to.autoAlpha === 1).length, 1); assert.equal(init.o2, undefined, "no outcome, no stamp"); assert.deepEqual(init["d0-0"], { opacity: 0.22, scale: 0.7 }, "a dot is there, small and dim, before its clip"); assert.deepEqual(cues.filter((c) => c.k === "rail").map((c) => c.to.autoAlpha), [0, 1]); // One element's cues never overlap. const byK = {}; for (const c of cues) { if (byK[c.k] !== undefined) assert.ok(c.at >= byK[c.k] - 1e-9, `${c.k} overlaps at ${c.at}`); byK[c.k] = c.at + c.dur; } }); test("planCues clamps a cue behind its element's last and carries the state", () => { const cues = planCues({ a: { x: 0 } }, [ { k: "a", at: 0, dur: 2, to: { x: 1 }, ease: "none" }, { k: "a", at: 1, dur: 2, to: { x: 2 }, ease: "none" }, ]); assert.deepEqual(cues.map((c) => [c.at, c.dur, c.from.x, c.to.x]), [[0, 2, 0, 1], [2, 1, 1, 2]]); }); test("the page: a card per thread, a dot per clip, an outcome where one is set", () => { const segments = SEGS.map(({ thread, ...s }) => s); const entries = SEGS.map((s) => ({ id: s.id, type: "clip", ...(s.thread ? { thread: s.thread } : {}) })); const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: RENDER }); assert.ok(schedule.threads, "the deck's schedule carries the rail"); assert.equal(segments.length, schedule.segments.length); const html = threadsHtml(schedule, RENDER, { fonts: { regular: "assets/r.ttf", bold: "assets/b.ttf" } }); assert.equal((html.match(/class="card"/g) ?? []).length, 3); assert.equal((html.match(/class="dot"/g) ?? []).length, 5); assert.equal((html.match(/class="outcome"/g) ?? []).length, 2); assert.match(html, /data-composition-id="threads"/); assert.match(html, />Still coming threadsHtml({ ...schedule, threads: undefined }, RENDER), /no thread rail/); // No rail, no `threads` in the schedule: a cut without one writes what it always did. const plain = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: PLAIN }); assert.equal("threads" in plain, false); }); test("railLayout: cards share the height, capped, the stack centred", () => { assert.deepEqual(railLayout(886, 5), { h: 166, gap: 14, top: 0 }); const three = railLayout(886, 3); assert.equal(three.h, 190); assert.equal(three.top, Math.floor((886 - 3 * 190 - 2 * 14) / 2)); }); test("the renderer runs without a display: a stale DISPLAY would break SwiftShader", async () => { const { rendererEnv } = await import("./compose-chrome.mjs"); const env = rendererEnv({ DISPLAY: ":0.0", WAYLAND_DISPLAY: "wayland-0", PATH: "/usr/bin", HOME: "/h" }); assert.deepEqual(env, { PATH: "/usr/bin", HOME: "/h" }); });