// Tests for ledger-totals.mjs. // // The fixture is a hand-built miniature of the real quartering-employee-count // ledger: the same shapes, the same two named landmines, and nothing else. Run // with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; import { ADJUDICATION_FIELDS, PREDICATES, UnadjudicatedLedger, adjudicationGaps, chronological, coherenceFlags, dateKey, isAdjudicated, ledgerTotals, rolesGaps, rosterAt, rosterLine, } from "./ledger-totals.mjs"; import { scheduleClaims, selectVariant } from "./build-video.mjs"; import { ledgerRevealAt, ledgerSeconds } from "./render-cards.mjs"; /** An adjudicated entry, with the six fields defaulted to the boring answer. */ const claim = (o) => ({ scope: "media", scopeBasis: "names the channel", scopeConfidence: "clear", population: "employees", valueKind: "uttered", flags: [], ...o, }); // --------------------------------------------------------------------------- // The gate // --------------------------------------------------------------------------- test("adjudicationGaps names every missing field", () => { assert.deepEqual(adjudicationGaps({ id: "x" }).sort(), [...ADJUDICATION_FIELDS].sort()); assert.deepEqual(adjudicationGaps(claim({ id: "x" })), []); assert.equal(isAdjudicated(claim({ id: "x" })), true); }); test("an empty scopeBasis is not an adjudication", () => { // An adjudication with no basis is an opinion, and the point of the exercise // was to stop shipping those. assert.deepEqual(adjudicationGaps(claim({ id: "x", scopeBasis: " " })), ["scopeBasis"]); }); test("each of the six is checked against its own vocabulary", () => { assert.deepEqual(adjudicationGaps(claim({ scope: "everything" })), ["scope"]); assert.deepEqual(adjudicationGaps(claim({ population: "staff" })), ["population"]); assert.deepEqual(adjudicationGaps(claim({ valueKind: "guessed" })), ["valueKind"]); assert.deepEqual(adjudicationGaps(claim({ scopeConfidence: "maybe" })), ["scopeConfidence"]); assert.deepEqual(adjudicationGaps(claim({ flags: "none" })), ["flags"]); }); test("ledgerTotals refuses to run on an unadjudicated ledger", () => { const led = [claim({ id: "a", date: "2022-01-01", value: 4 }), { id: "b", date: "2022-02-01", value: 5 }]; assert.throws(() => ledgerTotals(led), UnadjudicatedLedger); assert.throws(() => ledgerTotals(led), /b/); // …but computes over what it has when the inbox asks. const soft = ledgerTotals(led, { strict: false }); assert.deepEqual(soft.unadjudicated, ["b"]); assert.equal(soft.steps.length, 1); }); // --------------------------------------------------------------------------- // Ordering // --------------------------------------------------------------------------- test("month-only dates sort to the first of the month", () => { assert.equal(dateKey("2024-02"), "2024-02-01"); assert.equal(dateKey("2024-02-15"), "2024-02-15"); assert.equal(dateKey("garbage"), "9999-99-99"); const ids = chronological([ { id: "b", date: "2024-02-15" }, { id: "a", date: "2024-02" }, ]).map((e) => e.id); assert.deepEqual(ids, ["a", "b"]); }); test("ties break on id, so the order is total and stable", () => { const ids = chronological([ { id: "z", date: "2024-01-01" }, { id: "a", date: "2024-01-01" }, ]).map((e) => e.id); assert.deepEqual(ids, ["a", "z"]); }); // --------------------------------------------------------------------------- // The two series // --------------------------------------------------------------------------- const SERIES_FIXTURE = [ claim({ id: "m1", date: "2022-11-02", scope: "media", value: 5 }), claim({ id: "c1", date: "2023-04-19", scope: "coffee", value: 5, scopeBasis: "names coffee" }), claim({ id: "p1", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "names the publica" }), claim({ id: "e0", date: "2022-08-30", scope: "all", value: 4, scopeBasis: "no company named" }), ]; test("the implied total is our sum of his most recent per-company claims", () => { const t = ledgerTotals(SERIES_FIXTURE); const at = (id) => t.steps.find((s) => s.id === id); // 2022-08-30 is the FIRST entry: no company has spoken, so there is nothing // to sum. Our sum of no claims is not zero, it is undefined. assert.equal(at("e0").implied, null); assert.equal(at("e0").gap, null); // 2022-11-02: media alone. A sum of one is still our sum. assert.equal(at("m1").implied, 5); // 2023-04-19: media 5 + coffee 5 + publica 10. assert.equal(at("p1").implied, 20); assert.equal(t.final.implied, 20); }); test("the stated total is the last figure he utters for the whole payroll", () => { const t = ledgerTotals(SERIES_FIXTURE); assert.equal(t.final.stated, 4); // The exact landmark the plan calls for: implied 20 against stated 4. assert.equal(t.steps.find((s) => s.id === "p1").gap, 16); assert.deepEqual( t.series.stated.map((p) => [p.id, p.value]), [["e0", 4]], ); }); test("a derived sum never enters the stated series", () => { // This is the correction the whole re-cut turns on: 18 and 20 are OUR // arithmetic. He never utters either, so neither may be plotted as his claim. const led = [ claim({ id: "c", date: "2024-11-23", scope: "coffee", value: 10, scopeBasis: "names coffee" }), claim({ id: "m", date: "2024-11-23", scope: "media", value: 8 }), claim({ id: "e8", date: "2024-11-23", scope: "all", value: 18, valueKind: "derived", scopeBasis: "10 + 8, summed by us", }), ]; const t = ledgerTotals(led); assert.deepEqual(t.series.stated, []); assert.equal(t.final.stated, null); assert.equal(t.final.implied, 18); const flags = t.steps.find((s) => s.id === "e8").flags.map((f) => f.rule); assert.ok(flags.includes("not_his_number")); }); test("a synthetic midpoint never enters the stated series either", () => { const t = ledgerTotals([ claim({ id: "e11", date: "2025-05-30", scope: "all", value: 10.5, valueKind: "synthetic", population: "people", scopeBasis: "“about 10 people, 11 people full time”", }), ]); assert.equal(t.final.stated, null); const f = t.steps[0].flags.find((x) => x.rule === "not_his_number"); assert.equal(f.text, "our midpoint, not his figure"); }); test("an unresolved scope feeds neither total", () => { const t = ledgerTotals([ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }), claim({ id: "e?", date: "2024-08-14", scope: "all", value: 10, scopeConfidence: "unresolved", scopeBasis: "“I have 10 employees, my coffee company employees…” — comma decides it", }), ]); assert.equal(t.final.stated, null, "an unresolved claim must not become the stated total"); assert.equal(t.final.implied, 5); assert.deepEqual(t.unresolved, ["e?"]); }); test("a qualitative claim has no y position and moves neither number", () => { const t = ledgerTotals([ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }), claim({ id: "m12", date: "2024-02", scope: "media", value: null, scopeBasis: "hires a publisher" }), ]); const q = t.steps.find((s) => s.id === "m12"); assert.equal(q.qualitative, true); assert.equal(q.value, null); assert.equal(q.moved, null, "a value:null claim must move neither number"); assert.equal(q.implied, 5); }); test("deltas report the step, and only on the number that moved", () => { const t = ledgerTotals([ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }), claim({ id: "m2", date: "2022-02-01", scope: "media", value: 3 }), ]); const s = t.steps.find((x) => x.id === "m2"); assert.equal(s.impliedDelta, -2); assert.equal(s.statedDelta, null); assert.equal(s.moved, "implied"); }); // --------------------------------------------------------------------------- // The five predicates // --------------------------------------------------------------------------- test("contradicts_component: stated 6 against The Publica's 10 two days earlier", () => { // The case the plan names. Six cannot contain ten. const t = ledgerTotals([ claim({ id: "p01", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "names the publica" }), claim({ id: "e04", date: "2023-04-21", scope: "all", value: 6, scopeBasis: "no company named" }), ]); const f = t.steps.find((s) => s.id === "e04").flags.find((x) => x.rule === "contradicts_component"); assert.ok(f, "contradicts_component must fire"); assert.equal(f.against, "p01"); assert.equal(f.text, "he says 6 total; he said 10 at The Publica 2 days earlier"); }); test("contradicts_component does not fire when the total covers every component", () => { const t = ledgerTotals([ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 4, scopeBasis: "b" }), claim({ id: "e", date: "2023-04-21", scope: "all", value: 9, scopeBasis: "b" }), ]); assert.equal( t.steps.find((s) => s.id === "e").flags.filter((f) => f.rule === "contradicts_component").length, 0, ); }); test("same_day_conflict: two different totals on 2024-12-18", () => { // The other case the plan names: 10, then 10+10 as 20, same day, same scope. const t = ledgerTotals([ claim({ id: "e09", date: "2024-12-18", scope: "all", value: 10, scopeBasis: "no company named" }), claim({ id: "e10", date: "2024-12-18", scope: "all", value: 20, valueKind: "derived", scopeBasis: "10 + coffee's 10, summed by us", }), ]); for (const id of ["e09", "e10"]) { const f = t.steps.find((s) => s.id === id).flags.find((x) => x.rule === "same_day_conflict"); assert.ok(f, `same_day_conflict must fire on ${id}`); assert.match(f.text, /two different totals the same day/); } }); test("same_day_conflict ignores two claims about different companies", () => { const t = ledgerTotals([ claim({ id: "m", date: "2023-08-09", scope: "media", value: 4 }), claim({ id: "c", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "names coffee" }), ]); assert.equal(coherenceFlags([]).length, 0); for (const s of t.steps) { assert.equal(s.flags.filter((f) => f.rule === "same_day_conflict").length, 0); } }); test("same_day_conflict ignores a repeated identical figure", () => { const t = ledgerTotals([ claim({ id: "a", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "x" }), claim({ id: "b", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "x" }), ]); for (const s of t.steps) { assert.equal(s.flags.filter((f) => f.rule === "same_day_conflict").length, 0); } }); test("self_negating: a count of employees who are not employees", () => { const t = ledgerTotals([ claim({ id: "e09", date: "2024-12-18", scope: "all", value: 10, population: "contractor", scopeBasis: "no company named", }), ]); const f = t.steps[0].flags.find((x) => x.rule === "self_negating"); assert.ok(f); assert.match(f.text, /basically contractors/); }); test("population_mismatch: salaried against an employees baseline", () => { const led = [ claim({ id: "a", date: "2022-01-01", scope: "all", value: 4, scopeBasis: "x" }), claim({ id: "b", date: "2023-01-01", scope: "all", value: 6, scopeBasis: "x" }), claim({ id: "c", date: "2024-09-10", scope: "all", value: 5, population: "salaried", scopeBasis: "x" }), ]; const t = ledgerTotals(led); assert.equal(t.baseline.all, "employees"); const f = t.steps.find((s) => s.id === "c").flags.find((x) => x.rule === "population_mismatch"); assert.equal(f.text, "salaried, not employees"); // …and the two that match the baseline stay clean. for (const id of ["a", "b"]) { assert.equal( t.steps.find((s) => s.id === id).flags.filter((x) => x.rule === "population_mismatch").length, 0, ); } }); test("the baseline is per scope, not global", () => { const t = ledgerTotals([ claim({ id: "a", date: "2022-01-01", scope: "media", value: 4, population: "full-time" }), claim({ id: "b", date: "2022-02-01", scope: "media", value: 5, population: "full-time" }), claim({ id: "c", date: "2022-03-01", scope: "coffee", value: 6, scopeBasis: "x" }), ]); assert.equal(t.baseline.media, "full-time"); assert.equal(t.baseline.coffee, "employees"); assert.equal(t.steps.find((s) => s.id === "c").flags.length, 0); }); test("a large swing is deliberately NOT a flag", () => { // Fluctuation is the subject of the video, not a defect. Flagging it would be // putting a thumb on the scale. const t = ledgerTotals([ claim({ id: "a", date: "2025-02-12", scope: "media", value: 10 }), claim({ id: "b", date: "2025-06-20", scope: "media", value: 3 }), ]); assert.deepEqual(t.steps.find((s) => s.id === "b").flags, []); }); test("an adjudicator's hand-written flag rides alongside the computed ones", () => { const t = ledgerTotals([ claim({ id: "a", date: "2025-02-12", scope: "media", value: 10, flags: ["reading someone else's tweet"] }), ]); assert.deepEqual(t.steps[0].flags, [{ rule: "adjudicator", text: "reading someone else's tweet" }]); }); test("coherenceFlags flattens every fired predicate with its claim id", () => { const flags = coherenceFlags([ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "x" }), claim({ id: "e", date: "2023-04-21", scope: "all", value: 6, population: "salaried", valueKind: "derived", scopeBasis: "x", }), ]); const mine = flags.filter((f) => f.id === "e").map((f) => f.rule).sort(); assert.deepEqual(mine, ["contradicts_component", "not_his_number", "population_mismatch"]); }); // --------------------------------------------------------------------------- // The editorial decisions this corpus actually turned on. // // These are shaped like the real ledger rather than reading it: the file lives // outside the repo, and a test that loads it would fail for whoever does not // have that report checked out. What they pin is the RULING, so a later change // to the adjudication has to come past a red test rather than quietly restating // the video's argument. // --------------------------------------------------------------------------- test("December 2024: two tens on one day, and no stated total at all", () => { // He says ten at the channel and ten at the coffee company. Our sum is 20 and // he never utters it, so the implied line steps and the stated line does not // move — which is the whole correction, in one day of the corpus. const t = ledgerTotals([ claim({ id: "m", date: "2024-11-27", scope: "media", value: 10 }), claim({ id: "c13", date: "2024-12-18", scope: "coffee", value: 10, scopeBasis: "names coffee" }), claim({ id: "e09", date: "2024-12-18", scope: "media", value: 10, population: "contractor", scopeBasis: "“plus Coffee Brand Coffee, which ALSO has 10” — coffee is outside the ten", }), claim({ id: "e10", date: "2024-12-18", scope: "all", value: 20, valueKind: "derived", scopeBasis: "10 + 10, summed by us", }), ]); assert.equal(t.final.implied, 20); assert.equal(t.final.stated, null, "no figure he utters covers the whole payroll that day"); assert.deepEqual(t.series.stated, []); // e09 and c13 are the SAME day but different scopes, so this is not a // same-day conflict — it is two companies, which is the point. const e09 = t.steps.find((s) => s.id === "e09"); assert.equal(e09.flags.filter((f) => f.rule === "same_day_conflict").length, 0); assert.ok(e09.flags.some((f) => f.rule === "self_negating")); }); test("a stated total below a component fires even months later", () => { // 2024-09-10: five salaried across four ventures, against ten at the coffee // company eleven months earlier. The component claim is stale, not wrong, and // a total that cannot contain it is still incoherent. const t = ledgerTotals([ claim({ id: "c09", date: "2023-09-27", scope: "coffee", value: 10, scopeBasis: "names coffee" }), claim({ id: "e07", date: "2024-09-10", scope: "all", value: 5, population: "salaried", scopeBasis: "enumerates meme intros, thumbnails, tailgates and thepublica.com", }), ]); const f = t.steps.find((s) => s.id === "e07").flags.find((x) => x.rule === "contradicts_component"); assert.ok(f); assert.equal(f.against, "c09"); assert.match(f.text, /11 months earlier/); }); test("the implied total carries a company forward until he speaks about it again", () => { // The Publica is claimed once, in April 2023, and never again. Our sum keeps // carrying that ten for years — which is honest arithmetic over his claims and // exactly why the series must be labelled as ours. const t = ledgerTotals([ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "launch video" }), claim({ id: "m", date: "2025-06-20", scope: "media", value: 3, population: "people" }), ]); assert.equal(t.final.implied, 13); assert.equal(t.series.publica.length, 1, "one Publica claim in the whole corpus"); }); // --------------------------------------------------------------------------- // status_flip — the sixth predicate // --------------------------------------------------------------------------- test("status_flip: December's contractors are May's full-timers", () => { // The corpus's clearest reversal, and the reason the predicate compares // across scopes when one of them is `all`: the ten are the CHANNEL's in // December and the WHOLE payroll's in May, and reading those as unrelated // series is how the flip stayed invisible. const t = ledgerTotals([ claim({ id: "e09", date: "2024-12-18", scope: "media", value: 10, population: "contractor", scopeBasis: "“plus Coffee Brand Coffee, which ALSO has 10”", }), claim({ id: "e11", date: "2025-05-30", scope: "all", value: 10.5, population: "full-time", valueKind: "synthetic", scopeBasis: "“about 10 people, 11 people full time”", }), ]); const f = t.steps.find((s) => s.id === "e11").flags.find((x) => x.rule === "status_flip"); assert.ok(f, "status_flip must fire on the reversal"); assert.equal(f.against, "e09"); assert.equal(f.text, "5 months earlier they were contractors, now full time"); // …and the December claim itself has nothing before it to reverse. assert.equal( t.steps.find((s) => s.id === "e09").flags.filter((x) => x.rule === "status_flip").length, 0, ); }); test("status_flip names the figure when it is the same one", () => { const t = ledgerTotals([ claim({ id: "a", date: "2024-01-01", scope: "all", value: 10, population: "contractor", scopeBasis: "x" }), claim({ id: "b", date: "2024-03-01", scope: "all", value: 10, population: "full-time", scopeBasis: "x" }), ]); const f = t.steps.find((s) => s.id === "b").flags.find((x) => x.rule === "status_flip"); assert.equal(f.text, "2 months earlier the same 10 were contractors, now full time"); }); test("status_flip fires on a claim carrying no figure at all", () => { // "That's why all my workers are contract workers" is the single clearest // status claim in the corpus and it names no number. Gating the predicate on // a value would drop exactly the rows it exists to read. const t = ledgerTotals([ claim({ id: "e11", date: "2025-05-30", scope: "all", value: 10.5, population: "full-time", valueKind: "synthetic", scopeBasis: "x" }), claim({ id: "e12", date: "2025-10-29", scope: "all", value: null, population: "contractor", scopeBasis: "“all my workers are contract workers”" }), ]); const f = t.steps.find((s) => s.id === "e12").flags.find((x) => x.rule === "status_flip"); assert.ok(f); assert.equal(f.against, "e11"); }); test("status_flip only fires at the transition, not against every earlier claim", () => { // One 2022 "all 1099" would otherwise flag each of the next four claims in // turn, which reads as four findings when it is one. const led = [ claim({ id: "e01", date: "2022-03-16", scope: "all", value: null, population: "1099", scopeBasis: "x" }), claim({ id: "a", date: "2023-01-01", scope: "all", value: 4, population: "full-time", scopeBasis: "x" }), claim({ id: "b", date: "2023-06-01", scope: "all", value: 5, population: "full-time", scopeBasis: "x" }), claim({ id: "c", date: "2023-09-01", scope: "all", value: 6, population: "salaried", scopeBasis: "x" }), ]; const fired = ledgerTotals(led).steps .filter((s) => s.flags.some((f) => f.rule === "status_flip")) .map((s) => s.id); assert.deepEqual(fired, ["a"]); }); test("status_flip does not read `employees` or `people` as a status at all", () => { // He says "employees" when he is not making a claim about status. Treating // it as one side or the other manufactures a reversal out of vocabulary. const t = ledgerTotals([ claim({ id: "a", date: "2024-01-01", scope: "media", value: 4, population: "full-time" }), claim({ id: "b", date: "2024-06-01", scope: "media", value: 5, population: "employees" }), claim({ id: "c", date: "2024-09-01", scope: "media", value: 6, population: "people" }), ]); for (const s of t.steps) { assert.equal(s.flags.filter((f) => f.rule === "status_flip").length, 0); } }); test("status_flip does not compare two different companies", () => { const t = ledgerTotals([ claim({ id: "c", date: "2023-06-30", scope: "coffee", value: 6, population: "full-time", scopeBasis: "x" }), claim({ id: "p", date: "2023-09-01", scope: "publica", value: 3, population: "contractor", scopeBasis: "x" }), ]); assert.equal( t.steps.find((s) => s.id === "p").flags.filter((f) => f.rule === "status_flip").length, 0, ); }); test("an unresolved scope cannot say whose status reversed", () => { const t = ledgerTotals([ claim({ id: "u", date: "2022-03-16", scope: "all", value: null, population: "salaried", scopeConfidence: "unresolved", scopeBasis: "spoken on someone else's channel" }), claim({ id: "v", date: "2022-08-30", scope: "all", value: 4, population: "contractor", scopeBasis: "x" }), ]); for (const s of t.steps) { assert.equal(s.flags.filter((f) => f.rule === "status_flip").length, 0); } }); test("status_flip is one of the named predicates", () => { assert.ok(PREDICATES.includes("status_flip")); assert.equal(PREDICATES.length, 6); }); // --------------------------------------------------------------------------- // The roster // --------------------------------------------------------------------------- const ROSTERED = [ claim({ id: "m07", date: "2023-10-17", scope: "media", value: 3, population: "full-time", roles: [ { role: "video editor", count: 2, verbatim: "two video editors" }, { role: "graphics designer", count: 1, verbatim: "a graphics designer" }, ], }), claim({ id: "m08", date: "2023-12-04", scope: "media", value: 4, population: "full-time", roles: [ { role: "video editor", count: 2, verbatim: "two video editors" }, { role: "graphic designer", count: 1, verbatim: "a graphic designer" }, ], }), ]; test("rosterAt: the same two editors and one designer, whatever the total says", () => { // This is the whole finding. October's three and December's four are the SAME // roster; the total moved and the people did not. assert.equal(rosterAt(ROSTERED, "2023-10-17").id, "m07"); assert.equal(rosterAt(ROSTERED, "2023-11-30").id, "m07"); assert.equal(rosterAt(ROSTERED, "2023-12-04").id, "m08"); assert.equal(rosterAt(ROSTERED, "2026-01-01").id, "m08"); assert.equal(rosterAt(ROSTERED, "2023-01-01"), null, "nothing enumerated yet is not a roster"); assert.equal(rosterLine(rosterAt(ROSTERED, "2023-10-17").roles), "2 editors · 1 designer"); assert.equal(rosterLine(rosterAt(ROSTERED, "2023-12-04").roles), "2 editors · 1 designer"); }); test("rosterAt ignores claims that enumerate nothing", () => { const led = [...ROSTERED, claim({ id: "m19", date: "2025-06-20", scope: "media", value: 3, population: "people" })]; assert.equal(rosterAt(led, "2025-12-01").id, "m08"); }); test("roles are optional, and checked when present", () => { assert.deepEqual(rolesGaps(undefined), []); assert.deepEqual(adjudicationGaps(claim({ id: "x" })), [], "a claim with no roster is still adjudicated"); assert.deepEqual(rolesGaps([{ role: "video editor", count: 2, verbatim: "two video editors" }]), []); assert.deepEqual(rolesGaps([{ role: "", count: 2, verbatim: "x" }]), ["roles[0].role"]); assert.deepEqual(rolesGaps([{ role: "a", count: null, verbatim: "" }]), ["roles[0].count", "roles[0].verbatim"]); assert.deepEqual(rolesGaps([]), ["roles must be a non-empty array"]); }); // --------------------------------------------------------------------------- // Deleting the two derived rows // --------------------------------------------------------------------------- test("removing a derived `all` row moves neither series", () => { // 18 ("10+8") and 20 ("10+10") are our arithmetic over rows that are already // in the ledger. Taking them out has to change nothing at all — which is the // whole justification for taking them out. const real = [ claim({ id: "c12", date: "2024-11-23", scope: "coffee", value: 10, scopeBasis: "names coffee" }), claim({ id: "m14", date: "2024-11-23", scope: "media", value: 8 }), claim({ id: "e07", date: "2024-09-10", scope: "all", value: 5, population: "salaried", scopeBasis: "x" }), ]; const derived = claim({ id: "e08", date: "2024-11-23", scope: "all", value: 18, valueKind: "derived", scopeBasis: "10 + 8, summed by us", }); const withRow = ledgerTotals([...real, derived]); const without = ledgerTotals(real); assert.deepEqual(without.final, withRow.final); assert.deepEqual( without.series.implied.map((p) => p.value), withRow.series.implied.map((p) => p.value), ); assert.deepEqual(without.series.stated, withRow.series.stated); }); // --------------------------------------------------------------------------- // The variant filter // --------------------------------------------------------------------------- const VARIANT_MANIFEST = { timeline: [ { type: "card", id: "t00", style: "title", heading: "all 50", variants: { sourced: { heading: "the 20" } } }, { type: "clip", id: "a01" }, { type: "ledger", id: "L1", variant: "full" }, ], ledger: [ { id: "m02", entryId: "a01" }, { id: "m03", entryId: "L1" }, { id: "m04" }, ], }; test("selectVariant keeps only the claims whose entry survives", () => { const sourced = selectVariant(VARIANT_MANIFEST, "sourced"); assert.deepEqual(sourced.timeline.map((e) => e.id), ["t00", "a01"]); assert.deepEqual(sourced.ledger.map((c) => c.id), ["m02"]); const full = selectVariant(VARIANT_MANIFEST, "full"); assert.deepEqual(full.timeline.map((e) => e.id), ["t00", "a01", "L1"]); // m04 is pinned to nothing at all, so it is in neither cut — in `full` every // claim earns an entry, which is what makes the same filter serve both. assert.deepEqual(full.ledger.map((c) => c.id), ["m02", "m03"]); }); test("selectVariant merges a card's per-variant copy and drops the override key", () => { const t = selectVariant(VARIANT_MANIFEST, "sourced").timeline[0]; assert.equal(t.heading, "the 20"); assert.equal(t.variants, undefined); assert.equal(selectVariant(VARIANT_MANIFEST, "full").timeline[0].heading, "all 50"); }); test("selectVariant keeps a post with no variant in every cut and a variant post in its own", () => { const m = { ...VARIANT_MANIFEST, posts: [{ id: "p-all" }, { id: "p-full", variant: "full" }, { id: "p-src", variant: "sourced" }] }; assert.deepEqual(selectVariant(m, "sourced").posts.map((p) => p.id), ["p-all", "p-src"]); assert.deepEqual(selectVariant(m, "full").posts.map((p) => p.id), ["p-all", "p-full"]); assert.equal("posts" in selectVariant(VARIANT_MANIFEST, "full"), "posts" in VARIANT_MANIFEST); }); test("selectVariant refuses a variant nobody defined", () => { assert.throws(() => selectVariant(VARIANT_MANIFEST, "director's cut"), /unknown variant/); }); test("selectVariant does not mutate the manifest it was given", () => { const before = JSON.stringify(VARIANT_MANIFEST); selectVariant(VARIANT_MANIFEST, "sourced"); selectVariant(VARIANT_MANIFEST, "full"); assert.equal(JSON.stringify(VARIANT_MANIFEST), before); }); // --------------------------------------------------------------------------- // Scheduling claims onto the timeline // --------------------------------------------------------------------------- test("a claim on a stacked ledger card is pinned to its own row's reveal", () => { // Pinning all three to the segment's mid-dissolve lands three rail rows on // one frame AND breaks the pin-order guard, which needs strict monotonicity. const entries = [ { type: "card", id: "t00" }, { type: "clip", id: "a01" }, { type: "ledger", id: "L01", claims: ["m03", "c02", "c03"] }, { type: "clip", id: "b01" }, ]; const starts = [0, 6, 16, 30]; const ledger = [ { id: "m02", entryId: "a01" }, { id: "m03", entryId: "L01" }, { id: "c02", entryId: "L01" }, { id: "c03", entryId: "L01" }, { id: "c01", entryId: "b01" }, ]; const at = scheduleClaims(ledger, entries, starts, 0.4, 60); assert.equal(at[0], 6.2, "a clipped claim still lands mid-dissolve"); assert.equal(at[1], 16 + ledgerRevealAt(0)); assert.equal(at[2], 16 + ledgerRevealAt(1)); assert.equal(at[3], 16 + ledgerRevealAt(2)); assert.equal(at[4], 30.2); for (let i = 1; i < at.length; i += 1) assert.ok(at[i] > at[i - 1], `pin ${i} must move forward`); }); test("a card's own reveals all finish inside the card", () => { // `seconds` is derived from the same clock the pins read; if it were authored // by hand a short card would schedule rail rows past its own last frame. for (const n of [1, 2, 3, 4]) { assert.ok(ledgerRevealAt(n - 1) < ledgerSeconds(n), `${n} rows must all reveal`); } // Float arithmetic, so compare to the tolerance a frame actually has. assert.ok(Math.abs(ledgerSeconds(4) - (2.2 + 1.3 * 4)) < 1e-9); }); test("a pin that runs backwards is refused, not smoothed over", () => { // The ledger and the timeline disagreeing about the order of events is a // manifest bug, and the whole cut rests on the two agreeing. const entries = [{ type: "clip", id: "a" }, { type: "clip", id: "b" }]; assert.throws( () => scheduleClaims( [{ id: "x", entryId: "b" }, { id: "y", entryId: "a" }], entries, [0, 10], 0.4, 30, ), /not in the cut's order/, ); });