// Which schedule umtool's deck preview draws, and what a draft does to it. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import path from "node:path"; import test from "node:test"; import { estimateSchedule } from "umtool-report-to-video/deck"; import { postWindows } from "umtool-report-to-video/deck"; import { applyPostsDraft, clipLabel, composeFeedPreview, composePostsPreview, composeStampPreview, composePostsPreviews, normalizeClaimsDraft, normalizeDraft, normalizePostsDraft, postRows, previewSchedule, scheduleForPreview, scheduleMatches, segmentBoxes, stillTimeOf, } from "./onscreen.mjs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; const cut = () => ({ slug: "t", variant: "sourced", provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: { posts: { seconds: 2, hold: 0, shift: false } } } }, timeline: [ { type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 }, { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Saved", subtitle: "Saved sub" } }, ], }); const metas = [null, { title: "Stream one", uploadDate: "20240903" }, { title: "Stream two", uploadDate: "20240910" }]; /** A schedule as the build writes it: probed durations, the build's text. */ const built = () => ({ version: 1, kind: "deck", estimated: false, fps: 30, transition: 0.5, total: 25.9, multiChannel: false, segments: [ { id: "k1", type: "card", start: 0, duration: 5, end: 5, title: "Opening", subtitle: "a card", qrUrl: null, hideDeck: true }, { id: "c01", type: "clip", start: 4.5, duration: 9.9, end: 14.4, title: "", subtitle: "Built sub one", qrUrl: "https://q/1", hideDeck: false }, { id: "c02", type: "clip", start: 13.9, duration: 12, end: 25.9, title: "Old", subtitle: "Old sub", qrUrl: "https://q/2", hideDeck: false }, ], }); test("no build schedule: an estimate from the manifest, with the cue metadata in its subtitles", () => { const s = previewSchedule({ variantManifest: cut(), built: null, draft: new Map(), metas }); assert.equal(s.estimated, true); assert.deepEqual(s, estimateSchedule(cut(), { metas })); const c01 = s.segments.find((x) => x.id === "c01"); assert.match(c01.subtitle, /Stream one/); assert.match(c01.subtitle, /Sep 3, 2024/); }); test("no build schedule: the draft is applied before estimating, a row replacing the whole onscreen", () => { const draft = normalizeDraft({ c01: { title: " Drafted " }, c02: { title: "Only a title" }, k1: null }); const s = previewSchedule({ variantManifest: cut(), built: null, draft, metas }); const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); assert.equal(by.c01.title, "Drafted"); assert.equal(by.c02.title, "Only a title"); // c02's saved subtitle override is gone with the row: auto again. assert.match(by.c02.subtitle, /Stream two/); assert.equal(by.k1.title, "Opening"); }); test("a matching build schedule keeps its timings and QRs; the text is the manifest's NOW", () => { const draft = normalizeDraft({ c01: { title: "Drafted" } }); const s = previewSchedule({ variantManifest: cut(), built: built(), draft, metas }); assert.equal(s.estimated, false); assert.equal(s.total, 25.9); const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); assert.equal(by.c01.start, 4.5); assert.equal(by.c01.duration, 9.9); assert.equal(by.c01.qrUrl, "https://q/1"); assert.equal(by.c01.title, "Drafted"); // Saved since the build: the preview shows the save, not the build's text. assert.equal(by.c02.title, "Saved"); assert.equal(by.c02.subtitle, "Saved sub"); assert.equal(by.k1.hideDeck, true); }); test("a clip with no override and no cue file keeps the build's auto subtitle", () => { const s = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas: [null, null, null] }); const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); assert.equal(by.c01.subtitle, "Built sub one"); assert.equal(by.c02.subtitle, "Saved sub"); }); test("a build schedule that no longer lists the cut's entries in order is not used", () => { const m = cut(); m.timeline.reverse(); assert.equal(scheduleMatches(built(), m.timeline), false); const s = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas: [] }); assert.equal(s.estimated, true); assert.deepEqual(s.segments.map((x) => x.id), ["c02", "c01", "k1"]); assert.equal(scheduleMatches({ ...built(), kind: "rail" }, cut().timeline), false); assert.equal(scheduleMatches(built(), cut().timeline), true); }); test("normalizeDraft: names the entry a bad value belongs to", () => { assert.equal(normalizeDraft(undefined).size, 0); assert.equal(normalizeDraft(null).size, 0); assert.equal(normalizeDraft({ a: { title: " " } }).get("a"), null); assert.throws(() => normalizeDraft({ c01: { title: "a\nb" } }), /c01: .*one line/); assert.throws(() => normalizeDraft([1]), /must be an object/); }); test("stillTimeOf: the middle of an entry's segment", () => { assert.equal(stillTimeOf(built(), "c01"), 9.45); assert.equal(stillTimeOf(built(), "nope"), null); }); // ---- posts -------------------------------------------------------------------- // c01's record is from Sep 3, c02's from Sep 10 (metas above). const 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" }, { id: "p3", platform: "bluesky", handle: "a.test", date: "2024-08-01", text: "older than every clip", url: "https://bsky.app/p/3" }, ]; const withPosts = (posts = POSTS) => ({ ...cut(), posts: posts.map((p) => ({ ...p })) }); test("a matching build schedule: posts are placed again over the build's segments, from the manifest NOW", () => { // The build placed them somewhere else; a hide saved since must not show. const b = { ...built(), posts: [{ id: "p2", segment: "c01", slot: 0, of: 1, appear: 1, out: [2, 3] }] }; const m = withPosts(); m.posts[1].attachTo = "c01"; const s = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas }); const by = Object.fromEntries(s.posts.map((p) => [p.id, p])); // c01 leaves at c02's start (13.9) and carries p3, p1 and p2, oldest first. assert.deepEqual(s.posts.filter((p) => p.segment === "c01").map((p) => p.id), ["p3", "p1", "p2"]); assert.equal(by.p2.appear, 11.9); assert.deepEqual(by.p2.out, [13.9, 14.4]); assert.equal(by.p3.appear, 7.9); // A draft unhides nothing and moves p2 back to its own date's clip. const d = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas, postsDraft: { p2: { attachTo: null } } }); assert.equal(d.posts.find((p) => p.id === "p2").segment, "c02"); // c02 is the last segment: p2 leaves 0.3 s before the end. assert.deepEqual(d.posts.find((p) => p.id === "p2").out, [25.6, 25.9]); assert.deepEqual(postWindows(d).map((w) => w.segment), ["c01", "c02"]); // All hidden: no posts key, as a cut without them. const none = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas, postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } }, }); assert.ok(!("posts" in none)); }); test("no build schedule: the estimate places the posts with the draft applied", () => { const s = previewSchedule({ variantManifest: withPosts(), built: null, draft: new Map(), metas, postsDraft: { p3: { hide: true } } }); assert.equal(s.estimated, true); assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]); }); /** `cut()` with posts and the room-for-posts defaults (4 s, hold 2.5, shift on) instead of the old ones. */ const roomy = (posts = POSTS) => { const m = withPosts(posts); m.render.chrome.deck = {}; return m; }; test("a build that predates holds: each carrying clip gains the hold, every later start moves, and the moves follow", () => { // built(): k1 0+5, c01 4.5+9.9, c02 13.9+12 = 25.9. p3 and p1 ride on c01, p2 on c02. const s = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); assert.ok(!("hold" in by.k1)); assert.deepEqual([by.k1.start, by.k1.duration, by.k1.end], [0, 5, 5]); assert.deepEqual([by.c01.start, by.c01.duration, by.c01.end, by.c01.hold], [4.5, 12.4, 16.9, 2.5]); assert.deepEqual([by.c02.start, by.c02.duration, by.c02.end, by.c02.hold], [16.4, 14.5, 30.9, 2.5]); assert.equal(s.total, 30.9); // The posts are measured with the holds: c01's leave is c02's (held) start. const p = Object.fromEntries(s.posts.map((x) => [x.id, x])); assert.deepEqual(p.p1.out, [16.4, 16.9]); assert.equal(p.p1.appear, 12.4); assert.equal(p.p3.appear, 8.4); assert.deepEqual(p.p2.out, [30.6, 30.9]); // One move per carrying clip, at its first post, from the box to the shifted box. assert.deepEqual(s.moves.map((m) => [m.segment, m.at, m.segmentAt, m.seconds]), [["c01", 8.4, 3.9, 0.6], ["c02", 26.6, 10.2, 0.6]]); assert.deepEqual(s.moves[0].to, { x: 24, y: 64, width: 1354, height: 762 }); // A build WITH those holds is kept to the millisecond: the same schedule back. const again = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); assert.deepEqual(again.segments.map((x) => [x.id, x.start, x.duration, x.hold]), s.segments.map((x) => [x.id, x.start, x.duration, x.hold])); assert.equal(again.total, s.total); // Hiding c01's posts takes its hold away again, and the later starts come back. const hidden = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas, postsDraft: { p1: { hide: true }, p3: { hide: true } }, }); const h = Object.fromEntries(hidden.segments.map((x) => [x.id, x])); assert.ok(!("hold" in h.c01)); assert.deepEqual([h.c01.duration, h.c02.start, hidden.total], [9.9, 13.9, 28.4]); assert.deepEqual(hidden.moves.map((m) => m.segment), ["c02"]); // shift off: the holds, and no moves. const fixed = roomy(); fixed.render.chrome.deck = { posts: { shift: false } }; const off = previewSchedule({ variantManifest: fixed, built: built(), draft: new Map(), metas }); assert.equal(off.total, 30.9); assert.ok(!("moves" in off)); }); test("applyPostsDraft: the writer's rule, on a copy", () => { const posts = withPosts().posts; posts[0].hide = true; const out = applyPostsDraft(posts, { p1: { hide: false, attachTo: "c02" }, p2: { attachTo: null }, zz: { hide: true } }); assert.ok(!("hide" in out[0])); assert.equal(out[0].attachTo, "c02"); assert.ok(!("attachTo" in out[1])); assert.equal(posts[0].hide, true, "the input is not mutated"); assert.equal(applyPostsDraft(posts, {}), posts); assert.deepEqual(normalizePostsDraft(undefined), {}); assert.deepEqual(normalizePostsDraft({}), {}); assert.throws(() => normalizePostsDraft({ p1: { text: "x" } }), /only attachTo and hide/); }); test("postRows: the automatic clip, the effective one, and the slot in the schedule", () => { const m = withPosts(); m.posts[0].attachTo = "c02"; // p1 overridden m.posts[1].hide = true; // p2 hidden const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas }); const { posts, clips } = postRows({ variantManifest: m, metas, schedule }); const by = Object.fromEntries(posts.map((p) => [p.id, p])); assert.deepEqual(by.p1.auto, { entryId: "c01", rule: "date", clipDay: "2024-09-03", label: "Stream one" }); assert.equal(by.p1.effective.entryId, "c02"); assert.equal(by.p1.effective.rule, "attachTo"); assert.equal(by.p1.attachTo, "c02"); assert.equal(by.p1.effective.label, "Saved", "a clip's label is its deck title when it has one"); // Hidden: it still says where it WOULD ride, and rides nowhere. assert.equal(by.p2.hide, true); assert.equal(by.p2.auto.entryId, "c02"); assert.equal(by.p2.effective, null); assert.equal(by.p2.timing, null); assert.equal(by.p3.auto.rule, "first"); assert.deepEqual(by.p3.timing, { segment: "c01", slot: 0, of: 1, appear: 11.9, out: [13.9, 14.4] }); assert.deepEqual(clips, [ { id: "c01", label: "Stream one", day: "2024-09-03" }, { id: "c02", label: "Saved", day: "2024-09-10" }, ]); assert.deepEqual(postRows({ variantManifest: cut(), metas }).posts, []); }); test("clipLabel: deck title, then the quote, then the stream's title", () => { assert.equal(clipLabel({ onscreen: { title: "T" }, quote: "q" }, { title: "m" }), "T"); assert.equal(clipLabel({ quote: " q " }, { title: "m" }), "q"); assert.equal(clipLabel({}, { title: "m" }), "m"); assert.equal(clipLabel({}, null), ""); }); test("composePostsPreview: ONE call per window, region posts, preview, into posts-preview-", async () => { const calls = []; const project = { dir: "/proj" }; const compose = async (args) => { calls.push(args); return { projDir: path.join(args.outDir, "chrome", `posts-preview-${args.window.segment}`) }; }; const schedule = previewSchedule({ variantManifest: withPosts(), built: built(), draft: new Map(), metas }); const res = await composePostsPreviews(project, "sourced", schedule, { compose }); assert.deepEqual(res.map((w) => [w.segment, w.ok]), [["c01", true], ["c02", true]]); assert.deepEqual(calls[0], { manifestPath: path.join("/proj", "video.manifest.json"), outDir: path.join("/proj", "out", "sourced"), variant: "sourced", region: "posts", window: postWindows(schedule)[0], schedule, preview: true, }); // A composition written anywhere else is an error, said beside its window. const wrong = async () => ({ projDir: "/elsewhere" }); await assert.rejects( composePostsPreview(project, "sourced", schedule, postWindows(schedule)[0], { compose: wrong }), /not \/proj\/out\/sourced\/chrome\/posts-preview-c01/, ); const failing = async () => { throw new Error("unknown chrome region: posts"); }; const failed = await composePostsPreviews(project, "sourced", schedule, { compose: failing }); assert.deepEqual(failed.map((w) => [w.ok, w.error]), [[false, "unknown chrome region: posts"], [false, "unknown chrome region: posts"]]); }); // ---- the posts FEED (`posts.layout: "feed"`) ------------------------------------ /** `cut()` with posts in the feed layout. */ const feedy = (posts = POSTS) => { const m = withPosts(posts); m.render.chrome.deck = { posts: { layout: "feed" } }; return m; }; test("the feed: a popup build's holds come off, the posts tick in at their clip's start, no moves, no windows", () => { // A build made under the popup, with holds; the manifest says feed now. const popupBuilt = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); assert.ok(popupBuilt.segments.some((x) => x.hold > 0)); const s = previewSchedule({ variantManifest: feedy(), built: popupBuilt, draft: new Map(), metas }); assert.equal(s.layout, "feed"); // The segments are their probed lengths again (built()'s): no hold anywhere. assert.deepEqual(s.segments.map((x) => [x.id, x.start, x.duration, x.hold ?? 0]), [ ["k1", 0, 5, 0], ["c01", 4.5, 9.9, 0], ["c02", 13.9, 12, 0], ]); assert.equal(s.total, 25.9); assert.ok(!("moves" in s)); // c01 (in 5.0 after its 0.5 dissolve) carries p3 and p1, 4 s apart; c02 p2 at 14.4. assert.deepEqual(s.posts.map((p) => [p.id, p.segment, p.in]), [["p3", "c01", 5], ["p1", "c01", 9], ["p2", "c02", 14.4]]); assert.ok(s.posts.every((p) => !("appear" in p))); assert.deepEqual(postWindows(s), []); // Back to the popup: no layout key, the holds again. const back = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); assert.ok(!("layout" in back)); assert.ok(back.segments.some((x) => x.hold > 0)); // The estimate says the same about the layout. assert.equal(previewSchedule({ variantManifest: feedy(), built: null, draft: new Map(), metas }).layout, "feed"); // All posts hidden: a feed with nothing to draw is the deck alone. const none = previewSchedule({ variantManifest: feedy(), built: s, draft: new Map(), metas, postsDraft: { p1: { hide: true }, p2: { hide: true }, p3: { hide: true } }, }); assert.ok(!("layout" in none) && !("posts" in none)); }); test("the feed: the posts table's timing is the tick-in", () => { const m = feedy(); const schedule = previewSchedule({ variantManifest: m, built: built(), draft: new Map(), metas }); const by = Object.fromEntries(postRows({ variantManifest: m, metas, schedule }).posts.map((p) => [p.id, p])); assert.deepEqual(by.p2.timing, { segment: "c02", slot: 0, of: 1, in: 14.4 }); assert.equal(by.p2.effective.entryId, "c02"); }); test("composeFeedPreview: compose-chrome's feed region, into feed-preview, refused anywhere else", async () => { const calls = []; const project = { dir: "/proj" }; const schedule = previewSchedule({ variantManifest: feedy(), built: built(), draft: new Map(), metas }); const compose = async (args) => { calls.push(args); return { projDir: path.join(args.outDir, "chrome", "feed-preview") }; }; await composeFeedPreview(project, "sourced", schedule, { compose }); assert.deepEqual(calls[0], { manifestPath: path.join("/proj", "video.manifest.json"), outDir: path.join("/proj", "out", "sourced"), variant: "sourced", region: "feed", schedule, preview: true, }); await assert.rejects( composeFeedPreview(project, "sourced", schedule, { compose: async () => ({ projDir: "/elsewhere" }) }), /not \/proj\/out\/sourced\/chrome\/feed-preview/, ); }); test("segmentBoxes: each segment's recorded framing box, else the deck's; an odd id is never read", async () => { const dir = await mkdtemp(path.join(tmpdir(), "boxes-")); try { const segs = path.join(dir, "out", "sourced", "segments"); await mkdir(segs, { recursive: true }); const feedBox = { x: 24, y: 87, width: 1272, height: 716 }; await writeFile(path.join(segs, "c01.cut.json"), JSON.stringify({ version: 1, framing: { layout: "feed", box: feedBox } })); await writeFile(path.join(segs, "c02.cut.json"), JSON.stringify({ version: 1, start: 1, end: 2 })); const render = { width: 1920, height: 1080, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; const deckBox = { x: 173, y: 2, width: 1574, height: 886 }; const boxes = await segmentBoxes({ dir }, "sourced", [{ id: "c01" }, { id: "c02" }, { id: "k1" }, { id: "../x" }], render); assert.deepEqual(boxes, { c01: feedBox, c02: deckBox, k1: deckBox, "../x": deckBox }); } finally { await rm(dir, { recursive: true, force: true }); } }); // ---- post links --------------------------------------------------------------- test("a post that names its archive channel previews with the archive QR, built or estimated", () => { const m = withPosts([{ ...POSTS[1], siteChannel: "b-x" }]); for (const b of [built(), null]) { const s = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas }); assert.equal(s.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); assert.equal(s.posts[0].url, "https://x.com/b/status/2"); } }); test("scheduleForPreview: the posts are linked by the resolver the build uses, in memory only", async () => { const dir = await mkdtemp(path.join(tmpdir(), "onscreen-links-")); try { const manifest = withPosts([{ ...POSTS[1] }]); const asked = []; const resolver = { find: async (origin, post) => (asked.push([origin, post.id]), "b-x") }; const { variantManifest, schedule } = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), {}, { resolver }); assert.deepEqual(asked, [["https://example.test", "p2"]]); assert.equal(variantManifest.posts[0].siteChannel, "b-x"); assert.equal(manifest.posts[0].siteChannel, undefined, "the manifest is not changed"); assert.equal(schedule.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); } finally { await rm(dir, { recursive: true, force: true }); } }); test("scheduleForPreview: a post the draft un-hides is linked; a hidden one is not asked about", async () => { const dir = await mkdtemp(path.join(tmpdir(), "onscreen-links-")); try { const manifest = withPosts([{ ...POSTS[1], hide: true }]); const asked = []; const resolver = { find: async (origin, post) => (asked.push(post.id), "b-x") }; const hidden = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), {}, { resolver }); assert.deepEqual(asked, []); assert.ok(!("posts" in hidden.schedule)); const shown = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), { p2: { hide: false } }, { resolver }); assert.deepEqual(asked, ["p2"]); assert.equal(shown.schedule.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); assert.equal(shown.variantManifest.posts[0].hide, true, "the draft is the preview's, not the returned manifest's"); } finally { await rm(dir, { recursive: true, force: true }); } }); // ---- the fact-check claims --------------------------------------------------------- test("a draft row's claim: kept out of the text, read by normalizeClaimsDraft, refusals naming the entry", () => { const raw = { c01: { title: "T", claim: { id: " k1 ", verdict: "PARTLY" } }, c02: { claim: null }, k1: { title: "x" } }; assert.deepEqual([...normalizeDraft(raw)], [["c01", { title: "T" }], ["c02", null], ["k1", { title: "x" }]]); assert.deepEqual([...normalizeClaimsDraft(raw)], [["c01", { id: "k1", verdict: "PARTLY" }], ["c02", null]]); assert.throws(() => normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "SURE" } } }), /^Error: c01: claim\.verdict must be one of/); assert.throws(() => normalizeDraft({ c01: { claim: { id: "k1" } } }), /^Error: c01: claim\.verdict/); }); test("the claims draft stamps the preview: estimated, and over a matching build's segments", () => { const claimsDraft = normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "CONTRADICTED" } } }); const est = previewSchedule({ variantManifest: cut(), built: null, draft: new Map(), metas, claimsDraft }); assert.deepEqual(est.factcheck.stamps.map((x) => [x.claim, x.verdict, x.segment]), [["k1", "CONTRADICTED", "c01"]]); const b = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas, claimsDraft }); // c01 runs 4.5–14.4 in the build; it leaves with the dissolve into c02 (13.9). assert.deepEqual(b.factcheck.stamps, [{ claim: "k1", verdict: "CONTRADICTED", segment: "c01", at: 10.9, landed: 11.18, out: [13.9, 14.4] }]); assert.deepEqual(b.segments.find((x) => x.id === "c01").claim, { id: "k1", verdict: "CONTRADICTED" }); // A build that stamped a claim since removed in the draft stamps nothing now. const stampedBuild = { ...built(), segments: built().segments.map((x) => (x.id === "c02" ? { ...x, claim: { id: "k2", verdict: "PARTLY" } } : x)), factcheck: { stamps: [{ claim: "k2", verdict: "PARTLY", segment: "c02", at: 22.6, landed: 22.88, out: [25.6, 25.9] }] }, }; const cleared = previewSchedule({ variantManifest: cut(), built: stampedBuild, draft: new Map(), metas }); assert.equal("factcheck" in cleared, false); assert.equal("claim" in cleared.segments.find((x) => x.id === "c02"), false); // No claims anywhere: the schedule the preview always drew. assert.equal("factcheck" in previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas }), false); }); test("composeStampPreview: compose-chrome's stamp region, into stamp-preview, refused anywhere else", async () => { const calls = []; const project = { dir: "/proj" }; const claimsDraft = normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "PARTLY" } } }); const schedule = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas, claimsDraft }); const compose = async (args) => { calls.push(args); return { projDir: path.join(args.outDir, "chrome", "stamp-preview") }; }; await composeStampPreview(project, "sourced", schedule, { compose }); assert.deepEqual(calls[0], { manifestPath: path.join("/proj", "video.manifest.json"), outDir: path.join("/proj", "out", "sourced"), variant: "sourced", region: "stamp", schedule, preview: true, }); await assert.rejects( composeStampPreview(project, "sourced", schedule, { compose: async () => ({ projDir: "/elsewhere" }) }), /not \/proj\/out\/sourced\/chrome\/stamp-preview/, ); });