// The structural writers: move, remove, duplicate, insert, the teaser, the // posts, the fact-check, and undo. Each goes through the token, the lock, the // build's own checks and an automatic snapshot, 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, readdir, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { MANIFEST_NAME, StaleToken, StructureRefused, duplicateEntry, entryFromSpec, insertEntry, manifestToken, moveEntry, removeEntry, removePost, undoStructural, updateFactcheck, updateTeaser, upsertPost, } from "./manifest.mjs"; const base = () => ({ slug: "t", provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, timeline: [ { type: "teaser", id: "tz", lines: ["THE PROMISE"] }, { type: "clip", id: "c01", video: "v1", start: 10, end: 20, section: 1, sectionEnter: true }, { type: "clip", id: "c02", video: "v2", start: 30, end: 41, section: 1 }, { type: "clip", id: "c03", video: "v3", start: 50, end: 55, section: 2, sectionEnter: true }, ], posts: [ { id: "p1", platform: "x", date: "2024-01-02", text: "hello", url: "https://x.com/a/status/1", attachTo: "c02" }, ], }); async function project(manifest = base()) { const dir = await mkdtemp(path.join(tmpdir(), "umtool-structure-")); 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 ids = (m) => m.timeline.map((e) => e.id); const revisions = async (dir) => (await readdir(path.join(dir, "revisions")).catch(() => [])).sort(); test("moveEntry: re-orders, recomputes sectionEnter, snapshots once per burst", async () => { const dir = await project(); try { const r = await moveEntry(dir, "c03", 1, { token: await manifestToken(dir) }); assert.deepEqual([r.from, r.to], [3, 1]); const m = await read(dir); assert.deepEqual(ids(m), ["tz", "c03", "c01", "c02"]); // c03 (section 2) now first: it enters; c01 (section 1, after a 2) enters; c02 does not assert.equal(m.timeline[1].sectionEnter, true); assert.equal(m.timeline[2].sectionEnter, true); assert.equal("sectionEnter" in m.timeline[3], false); // The file is still the CLI's formatting. assert.equal(await readRaw(dir), JSON.stringify(m, null, 2) + "\n"); assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1); await moveEntry(dir, "c03", 3); assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1, "throttled"); await assert.rejects(moveEntry(dir, "c03", 3), /already at 3/); await assert.rejects(moveEntry(dir, "c03", 9), /toIndex/); await assert.rejects(moveEntry(dir, "nope", 0), /no timeline entry/); } finally { await rm(dir, { recursive: true }); } }); test("a stale token refuses every structural write and leaves the file alone", async () => { const dir = await project(); try { const before = await readRaw(dir); for (const call of [ () => moveEntry(dir, "c03", 1, { token: "1" }), () => removeEntry(dir, "c03", { token: "1" }), () => duplicateEntry(dir, "c03", { token: "1" }), () => insertEntry(dir, "c03", "chan/vx@1-5", { token: "1" }), () => updateTeaser(dir, "tz", { beat: 1 }, { token: "1" }), () => upsertPost(dir, { id: "p2" }, { token: "1" }), () => removePost(dir, "p1", { token: "1" }), () => undoStructural(dir, { token: "1" }), ]) { await assert.rejects(call(), StaleToken); } assert.equal(await readRaw(dir), before); } finally { await rm(dir, { recursive: true }); } }); test("removeEntry: refused while a post rides on the clip; fine once it does not", async () => { const dir = await project(); try { const before = await readRaw(dir); await assert.rejects(removeEntry(dir, "c02"), (e) => e instanceof StructureRefused && /attachTo/.test(e.message)); assert.equal(await readRaw(dir), before); const r = await removeEntry(dir, "c03"); assert.equal(r.removed.id, "c03"); assert.deepEqual(ids(await read(dir)), ["tz", "c01", "c02"]); } finally { await rm(dir, { recursive: true }); } }); test("duplicateEntry and insertEntry: fresh ids, the clip spec, 2 dp, at the right place", async () => { const dir = await project(); try { const d = await duplicateEntry(dir, "c01"); assert.equal(d.id, "c01-copy"); const again = await duplicateEntry(dir, "c01"); assert.equal(again.id, "c01-copy-2"); const ins = await insertEntry(dir, "c02", "mychan/abc123@12.3456-20.1"); assert.deepEqual(ins.entry, { type: "clip", id: "abc123-12", channel: "mychan", video: "abc123", start: 12.35, end: 20.1 }); const first = await insertEntry(dir, null, { type: "card", heading: "Start" }); assert.equal(first.at, 0); assert.equal(first.id, "card"); const m = await read(dir); assert.deepEqual(ids(m), ["card", "tz", "c01", "c01-copy-2", "c01-copy", "c02", "abc123-12", "c03"]); await assert.rejects(insertEntry(dir, "c02", "not a spec"), /channel/); await assert.rejects(insertEntry(dir, "c02", "chan/v@5-5.2"), /half a second/); // A teaser that the build would refuse is refused here. await assert.rejects(insertEntry(dir, "c02", { type: "teaser", lines: [] }), StructureRefused); } finally { await rm(dir, { recursive: true }); } }); test("entryFromSpec never reuses an id", () => { const tl = [{ id: "a" }, { id: "v-1" }]; assert.equal(entryFromSpec(tl, { type: "card", id: "a" }).id, "a-2"); assert.equal(entryFromSpec(tl, "c/v@1-3").id, "v-1-2"); }); test("updateTeaser: lines, beat, tail, dip — validated by the build's own check", async () => { const dir = await project(); try { const r = await updateTeaser(dir, "tz", { lines: ["THE PROMISE", "AND WHAT HAPPENED"], beat: 1.2345, tail: "?", tailWait: 1 }); assert.equal(r.entry.beat, 1.23); await assert.rejects(updateTeaser(dir, "tz", { lines: [] }), StructureRefused); await assert.rejects(updateTeaser(dir, "tz", { tail: null }), /tailWait/); await updateTeaser(dir, "tz", { tail: null, tailWait: null }); const m = await read(dir); assert.equal("tail" in m.timeline[0], false); await assert.rejects(updateTeaser(dir, "c01", { beat: 1 }), /not a teaser/); await assert.rejects(updateTeaser(dir, "tz", { seconds: 4 }), /not something/); } finally { await rm(dir, { recursive: true }); } }); test("upsertPost / removePost: add, replace keeping attachTo, refuse what validatePosts refuses", async () => { const dir = await project(); try { const add = await upsertPost(dir, { id: "p2", platform: "bluesky", date: "2024-03-04", text: " words ", url: "https://bsky.app/x", author: "" }); assert.equal(add.created, true); assert.deepEqual(add.post, { id: "p2", platform: "bluesky", date: "2024-03-04", text: "words", url: "https://bsky.app/x" }); const rep = await upsertPost(dir, { id: "p1", platform: "x", date: "2024-01-02", text: "edited", url: "https://x.com/a/status/1" }); assert.equal(rep.post.attachTo, "c02"); await assert.rejects(upsertPost(dir, { id: "p3", platform: "myspace", date: "2024", text: "x", url: "http://no" }), StructureRefused); await removePost(dir, "p2"); await removePost(dir, "p1"); assert.equal("posts" in (await read(dir)), false); await assert.rejects(removePost(dir, "p1"), /no post/); } finally { await rm(dir, { recursive: true }); } }); test("updateFactcheck: needs the deck; labels and colours checked by validateChrome", async () => { const dir = await project(); try { await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 3 } }), /deck on first/); const m = await read(dir); m.render.chrome = { engine: "hyperframes", layout: "deck" }; await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(m, null, 2) + "\n"); const r = await updateFactcheck(dir, { verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } }, stamp: { seconds: 4 } }); assert.equal(r.factcheck.stamp.seconds, 4); await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 99 } }), StructureRefused); await updateFactcheck(dir, null); assert.equal("factcheck" in (await read(dir)).render.chrome, false); } finally { await rm(dir, { recursive: true }); } }); test("undoStructural restores the newest automatic snapshot, keeps the current state, and walks back", async () => { const dir = await project(); try { const original = await readRaw(dir); await assert.rejects(undoStructural(dir), /nothing to undo/); await moveEntry(dir, "c03", 1); // Age the move's snapshot so the next op is not throttled into it. const rev = path.join(dir, "revisions"); for (const n of await readdir(rev)) await utimes(path.join(rev, n), new Date(Date.now() - 600_000), new Date(Date.now() - 600_000)); await new Promise((r) => setTimeout(r, 1100)); await removeEntry(dir, "c01"); const afterRemove = ids(await read(dir)); assert.deepEqual(afterRemove, ["tz", "c03", "c02"]); const u1 = await undoStructural(dir); assert.equal(u1.op, "remove"); assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); await new Promise((r) => setTimeout(r, 1100)); const u2 = await undoStructural(dir); assert.equal(u2.op, "move"); assert.equal(await readRaw(dir), original, "byte for byte"); const names = await revisions(dir); assert.ok(names.some((n) => n.includes("undone-move"))); assert.ok(names.some((n) => n.includes("undo-saved"))); } finally { await rm(dir, { recursive: true }); } }); test("a manifest that already has a build problem can still be re-ordered", async () => { const m = base(); m.posts[0].attachTo = "gone"; // already broken before the edit const dir = await project(m); try { await moveEntry(dir, "c03", 1); assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); } finally { await rm(dir, { recursive: true }); } });