// The deck's writers: updateOnscreen, updateChrome, and updateClip's // `onscreen`. Each one goes through the lock, the backup, the atomic write and // the stale-token guard, and each refusal leaves the file byte-for-byte as it // was. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { ChromeRefused, MANIFEST_NAME, PostsRefused, StaleToken, manifestToken, normalizePostPatches, updateChrome, updateClip, updateOnscreen, updatePosts, } from "./manifest.mjs"; import { validateCutEdits } from "umtool-report-to-video/deck"; const base = () => ({ slug: "t", provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, timeline: [ { type: "card", id: "k1", heading: "Opening", sub: "a card" }, { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, { type: "image", id: "i1", file: "x.png", seconds: 4 }, { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Kept" } }, ], }); async function project(manifest = base()) { const dir = await mkdtemp(path.join(tmpdir(), "umtool-deck-")); await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n"); return dir; } const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8"); const read = async (dir) => JSON.parse(await readRaw(dir)); const entry = (m, id) => m.timeline.find((e) => e.id === id); test("updateOnscreen: round trip on every entry type, trimmed, in the CLI's formatting", async () => { const dir = await project(); try { const token = await manifestToken(dir); const res = await updateOnscreen( dir, { k1: { title: " Card title " }, c01: { title: "County approves pre-application", subtitle: " Override · 2024 " }, i1: { subtitle: "Still" }, }, { token }, ); assert.deepEqual(res.onscreen.k1, { title: "Card title" }); assert.equal(res.token, await manifestToken(dir)); const raw = await readRaw(dir); assert.ok(raw.endsWith("}\n"), "trailing newline kept"); assert.ok(raw.startsWith('{\n "slug"'), "two-space indent kept"); const m = JSON.parse(raw); assert.deepEqual(entry(m, "k1").onscreen, { title: "Card title" }); assert.deepEqual(entry(m, "c01").onscreen, { title: "County approves pre-application", subtitle: "Override · 2024", }); assert.deepEqual(entry(m, "i1").onscreen, { subtitle: "Still" }); // Untouched rows stay as they were. assert.deepEqual(entry(m, "c02").onscreen, { title: "Kept" }); // The backup of the previous state sits beside it. assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`))); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: a row replaces the whole onscreen; empty and null delete the key", async () => { const dir = await project(); try { await updateOnscreen(dir, { c01: { title: "T", subtitle: "S" } }); await updateOnscreen(dir, { c01: { title: "T2" } }); assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "T2" }); await updateOnscreen(dir, { c01: { title: " ", subtitle: "" }, c02: null }); const m = await read(dir); assert.equal("onscreen" in entry(m, "c01"), false); assert.equal("onscreen" in entry(m, "c02"), false); await updateOnscreen(dir, { k1: { title: "x" } }); await updateOnscreen(dir, { k1: {} }); assert.equal("onscreen" in entry(await read(dir), "k1"), false); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: an unknown id refuses the WHOLE batch and writes nothing", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects( updateOnscreen(dir, { c01: { title: "would be written" }, nope: { title: "x" } }), /no timeline entry with id nope/, ); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: a value the deck refuses fails the batch, naming the entry", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects(updateOnscreen(dir, { c01: { title: "ok" }, k1: { title: "two\nlines" } }), /k1: .*one line/); await assert.rejects(updateOnscreen(dir, { c01: { heading: "x" } }), /c01: onscreen\.heading is not/); await assert.rejects(updateOnscreen(dir, { c01: { title: "x".repeat(201) } }), /at most 200/); await assert.rejects(updateOnscreen(dir, {}), /nothing to change/); await assert.rejects(updateOnscreen(dir, null), /must be an object/); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: a stale token is refused and writes nothing", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects(updateOnscreen(dir, { c01: { title: "x" } }, { token: "1" }), (e) => { assert.ok(e instanceof StaleToken); assert.equal(e.expected, "1"); return true; }); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: every variant twin with the id gets the text", async () => { const m = base(); m.timeline.push({ type: "clip", id: "c01", variant: "full", video: "v1", start: 9, end: 22 }); m.timeline[1].variant = "sourced"; const dir = await project(m); try { await updateOnscreen(dir, { c01: { title: "Both" } }); const out = (await read(dir)).timeline.filter((e) => e.id === "c01"); assert.equal(out.length, 2); for (const e of out) assert.deepEqual(e.onscreen, { title: "Both" }); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateClip: onscreen is normalised, and empty or null deletes the key", async () => { const dir = await project(); try { const res = await updateClip(dir, "c01", { onscreen: { title: " Bench title " } }); assert.deepEqual(res.entry.onscreen, { title: "Bench title" }); assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "Bench title" }); await updateClip(dir, "c01", { onscreen: { title: "", subtitle: " " } }); assert.equal("onscreen" in entry(await read(dir), "c01"), false); await updateClip(dir, "c02", { onscreen: null }); assert.equal("onscreen" in entry(await read(dir), "c02"), false); const before = await readRaw(dir); await assert.rejects(updateClip(dir, "c01", { onscreen: { title: "a\nb" } }), /one line/); await assert.rejects(updateClip(dir, "c01", { onscreen: "a string" }), /must be an object/); // A refused value writes nothing. assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateClip: muteFrom is a number inside the clip, rounded; empty or null deletes the key", async () => { const dir = await project(); try { const res = await updateClip(dir, "c01", { muteFrom: 18.456 }); assert.equal(res.entry.muteFrom, 18.46); assert.equal(entry(await read(dir), "c01").muteFrom, 18.46); // A string from a form is a number too. await updateClip(dir, "c01", { muteFrom: "17.5" }); assert.equal(entry(await read(dir), "c01").muteFrom, 17.5); const before = await readRaw(dir); // Outside [start, end], and not a number: refused, nothing written. await assert.rejects(updateClip(dir, "c01", { muteFrom: 25 }), /must lie inside the clip 10–20/); await assert.rejects(updateClip(dir, "c01", { muteFrom: 5 }), /must lie inside/); await assert.rejects(updateClip(dir, "c01", { muteFrom: "later" }), /must be a number/); // A window that would leave the mark outside it is refused too... await assert.rejects(updateClip(dir, "c01", { end: 17 }), /mute mark 17.5 must lie inside the window 10–17/); assert.equal(await readRaw(dir), before); // ...unless the same patch moves or clears it. await updateClip(dir, "c01", { end: 17, muteFrom: 16 }); assert.equal(entry(await read(dir), "c01").muteFrom, 16); // Checked against the window AFTER the patch: a wider window and a mark in // the new seconds land together. await updateClip(dir, "c01", { end: 22, muteFrom: 21 }); assert.equal(entry(await read(dir), "c01").muteFrom, 21); await updateClip(dir, "c01", { muteFrom: "" }); assert.equal("muteFrom" in entry(await read(dir), "c01"), false); await updateClip(dir, "c01", { muteFrom: 12 }); await updateClip(dir, "c01", { muteFrom: null }); assert.equal("muteFrom" in entry(await read(dir), "c01"), false); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateClip: a window edge moved within 0.02 s past the mute mark clamps the mark onto it, so the build accepts what was saved", async () => { const dir = await project(); try { // c01 is 10–20. A mark on the start, then the start moved a hundredth on. await updateClip(dir, "c01", { muteFrom: 10 }); await updateClip(dir, "c01", { start: 10.01 }); let m = await read(dir); assert.equal(entry(m, "c01").muteFrom, 10.01, "the mark moves onto the new start"); assert.deepEqual(validateCutEdits(m), [], "and the build's strict check takes it"); // The same at the end. await updateClip(dir, "c01", { muteFrom: 20 }); await updateClip(dir, "c01", { end: 19.99 }); m = await read(dir); assert.equal(entry(m, "c01").muteFrom, 19.99); assert.deepEqual(validateCutEdits(m), []); // Further than the tolerance is still refused. await assert.rejects(updateClip(dir, "c01", { end: 19.9 }), /mute mark 19.99 must lie inside the window/); } finally { await rm(dir, { recursive: true, force: true }); } }); const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } }; test("updateChrome: round trip stores the block as given; null removes it", async () => { const dir = await project(); try { const token = await manifestToken(dir); const res = await updateChrome(dir, DECK, { token }); assert.deepEqual(res.chrome, DECK); assert.equal(res.token, await manifestToken(dir)); let m = await read(dir); assert.deepEqual(m.render.chrome, DECK); // The rest of the render block is untouched and keeps its order. assert.deepEqual(Object.keys(m.render), ["width", "height", "fps", "transition", "chrome"]); // Replacing keeps the key where it was. await updateChrome(dir, { engine: "hyperframes", layout: "deck" }); m = await read(dir); assert.deepEqual(m.render.chrome, { engine: "hyperframes", layout: "deck" }); const off = await updateChrome(dir, null); assert.equal(off.chrome, null); m = await read(dir); assert.equal("chrome" in m.render, false); assert.equal(m.render.fps, 30); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateChrome: a manifest with no render block gets one", async () => { const m = base(); delete m.render; const dir = await project(m); try { await updateChrome(dir, DECK); assert.deepEqual((await read(dir)).render, { chrome: DECK }); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateChrome: validateChrome's sentences come back whole, and nothing is written", async () => { const dir = await project(); try { const before = await readRaw(dir); const refused = async (chrome, ...want) => { await assert.rejects(updateChrome(dir, chrome), (e) => { assert.ok(e instanceof ChromeRefused, String(e)); for (const w of want) assert.ok(e.errors.some((s) => w.test(s)), `${w} in ${JSON.stringify(e.errors)}`); return true; }); assert.equal(await readRaw(dir), before); }; await refused( { engine: "hyperframes", layout: "deck", deck: { height: 50, footageScle: 0.8 } }, /deck\.height must be a whole number/, /deck\.footageScle is not a deck setting/, ); await refused({ engine: "ffmpeg", layout: "deck" }, /engine must be "hyperframes"/); await refused({ engine: "hyperframes", layout: "deck", deck: { qr: { size: 300 } } }, /qr\.size 300 does not fit/); await refused("deck", /must be an object/); await assert.rejects(updateChrome(dir, undefined), /an object, or null/); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateChrome: refused beside a rail or the legacy chromeEngine — checked against the REST of render", async () => { const m = base(); m.render.rail = { kind: "x" }; m.render.chromeEngine = "hyperframes"; const dir = await project(m); try { await assert.rejects(updateChrome(dir, DECK), (e) => { assert.ok(e instanceof ChromeRefused); assert.ok(e.errors.some((s) => /render\.rail cannot both be set/.test(s))); assert.ok(e.errors.some((s) => /remove chromeEngine/.test(s))); return true; }); // Turning the deck OFF is never refused: it is how such a manifest is fixed. await updateChrome(dir, null); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateChrome: a stale token is refused and writes nothing", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects(updateChrome(dir, DECK, { token: "1" }), StaleToken); await assert.rejects(updateChrome(dir, null, { token: "1" }), StaleToken); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("the writers queue: concurrent saves of different fields all land", async () => { const dir = await project(); try { await Promise.all([ updateOnscreen(dir, { k1: { title: "A" } }), updateChrome(dir, DECK), updateClip(dir, "c01", { onscreen: { title: "B" } }), updateOnscreen(dir, { i1: { title: "C" } }), ]); const m = await read(dir); assert.deepEqual(entry(m, "k1").onscreen, { title: "A" }); assert.deepEqual(entry(m, "c01").onscreen, { title: "B" }); assert.deepEqual(entry(m, "i1").onscreen, { title: "C" }); assert.deepEqual(m.render.chrome, DECK); } finally { await rm(dir, { recursive: true, force: true }); } }); // ---- updatePosts ------------------------------------------------------------ const withPosts = () => ({ ...base(), posts: [ { id: "p1", platform: "bluesky", handle: "a.test", date: "2024-09-05T10:00:00Z", text: "one", url: "https://bsky.app/p/1" }, { id: "p2", platform: "x", handle: "b", date: "2024-09-12", text: "two", url: "https://x.com/b/status/2", attachTo: "c01", hide: true }, ], }); const post = (m, id) => m.posts.find((p) => p.id === id); test("updatePosts: attachTo and hide round trip; null and false delete the key; the rest is untouched", async () => { const dir = await project(withPosts()); try { const token = await manifestToken(dir); const res = await updatePosts(dir, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } }, { token }); assert.deepEqual(res.posts, { p1: { attachTo: "c02", hide: true }, p2: { attachTo: null, hide: false } }); assert.equal(res.token, await manifestToken(dir)); const raw = await readRaw(dir); assert.ok(raw.startsWith('{\n "slug"') && raw.endsWith("}\n"), "the CLI's formatting kept"); const m = JSON.parse(raw); assert.equal(post(m, "p1").attachTo, "c02"); assert.equal(post(m, "p1").hide, true); assert.ok(!("attachTo" in post(m, "p2")), "attachTo: null deletes the key"); assert.ok(!("hide" in post(m, "p2")), "hide: false deletes the key"); assert.equal(post(m, "p2").text, "two"); assert.deepEqual(m.timeline, base().timeline); assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`))); // A key the patch does not name stays; "" is the select's "auto". await updatePosts(dir, { p1: { hide: false } }); assert.equal(post(await read(dir), "p1").attachTo, "c02"); await updatePosts(dir, { p1: { attachTo: "" } }); assert.deepEqual(post(await read(dir), "p1"), withPosts().posts[0]); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updatePosts: an unknown post id refuses the WHOLE batch and writes nothing", async () => { const dir = await project(withPosts()); try { const before = await readRaw(dir); await assert.rejects(updatePosts(dir, { p1: { hide: true }, nope: { hide: true } }), /no post with id nope/); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updatePosts: an attachTo validatePosts refuses comes back as its sentence, and nothing is written", async () => { const dir = await project(withPosts()); try { const before = await readRaw(dir); // k1 is a card, not a clip: a post rides on footage. await assert.rejects(updatePosts(dir, { p1: { hide: true }, p2: { attachTo: "k1" } }), (e) => { assert.ok(e instanceof PostsRefused); assert.deepEqual(e.errors, ['posts[1].attachTo "k1" is not a clip in the timeline']); return true; }); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updatePosts: a stale token is refused and writes nothing", async () => { const dir = await project(withPosts()); try { const before = await readRaw(dir); await assert.rejects(updatePosts(dir, { p1: { hide: true } }, { token: "1" }), (e) => e instanceof StaleToken); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updatePosts: no posts in the manifest, or a patch it does not take, is refused", async () => { const dir = await project(); try { await assert.rejects(updatePosts(dir, { p1: { hide: true } }), /has no posts/); } finally { await rm(dir, { recursive: true, force: true }); } assert.throws(() => normalizePostPatches({}), /nothing to change/); assert.throws(() => normalizePostPatches([]), /must be an object/); assert.throws(() => normalizePostPatches({ p1: { text: "new words" } }), /text is not something this writer changes/); assert.throws(() => normalizePostPatches({ p1: { hide: "yes" } }), /p1: hide must be true or false/); assert.throws(() => normalizePostPatches({ p1: { attachTo: 3 } }), /p1: attachTo must be a clip id/); assert.deepEqual(normalizePostPatches({ p1: { attachTo: "" }, p2: {} }), { p1: { attachTo: null }, p2: {} }); }); test("updateOnscreen: a row's claim is set beside onscreen, round-trips, and null removes it; a row without one keeps it", async () => { const dir = await project(); try { const res = await updateOnscreen(dir, { c01: { title: "T", claim: { id: " k1 ", verdict: "PARTLY" } }, i1: { claim: { id: "k1", verdict: "PARTLY" } }, }); assert.deepEqual(res.claims, { c01: { id: "k1", verdict: "PARTLY" }, i1: { id: "k1", verdict: "PARTLY" } }); let m = await read(dir); assert.deepEqual(entry(m, "c01").claim, { id: "k1", verdict: "PARTLY" }); assert.deepEqual(entry(m, "c01").onscreen, { title: "T" }, "the claim is not inside onscreen"); assert.equal("onscreen" in entry(m, "i1"), false); // The clip review's `verdict` is a different key, and untouched. assert.equal("verdict" in entry(m, "c01"), false); // A row without `claim` leaves it; null removes it. await updateOnscreen(dir, { c01: { title: "T2" } }); m = await read(dir); assert.deepEqual(entry(m, "c01").claim, { id: "k1", verdict: "PARTLY" }); await updateOnscreen(dir, { c01: { title: "T2", claim: null } }); m = await read(dir); assert.equal("claim" in entry(m, "c01"), false); assert.equal(entry(m, "c01").onscreen.title, "T2"); } finally { await rm(dir, { recursive: true, force: true }); } }); test("updateOnscreen: a bad claim, or one claim id given two verdicts, refuses the batch and writes nothing", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects(updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "MAYBE" } } }), /c01: claim\.verdict must be one of/); await assert.rejects(updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "PARTLY", why: "x" } } }), /c01: claim\.why is not a claim field/); await assert.rejects( updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "PARTLY" } }, c02: { title: "Kept", claim: { id: "k1", verdict: "CONTRADICTED" } } }), /claim: .*claim k1 is CONTRADICTED here and PARTLY at .* — nothing was written/, ); assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true, force: true }); } });