commit 468254eca601d46f8457029647ef49d4e48b5d56
parent 22c5b1c3393cefbf099839eb92cd993ab83a8825
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 00:49:07 -0400
umtool: e2e for posts on the deck — onscreen-posts.spec.ts and its fixture
onscreen-posts-fixture: two clips with their own dates and a card, and three
posts the date rule puts on c01 ("first"), c01 ("date") and c02 ("date").
The spec covers the table's automatic attachment, an override saved into the
manifest and back to auto (the key deleted), hide across a reload, a 409 that
keeps the edit and shows the other writer's, the writer's refusals, and the
preview's posts windows: postWindows over the returned schedule, the overlay
at postsGeometry while the scrubber is inside one and absent outside. When
compose-chrome has no posts region the route's per-window refusal is asserted
and the run is annotated; once it has one, the window's page must report
posts:ready.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 301 insertions(+), 1 deletion(-)
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1506,6 +1506,41 @@ const ONSCREEN_BUILD = writeProject(
]),
);
+// onscreen-posts-fixture: the Posts table writes here (onscreen-posts.spec.ts).
+// The clips carry their own `date`, so which clip a post rides on is the
+// date rule's answer and not the cue file's one shared upload day:
+// p-early Aug 1 older than every clip -> c01 ("first")
+// p-mid Sep 5 after c01's Sep 3 -> c01 ("date")
+// p-late Sep 12 after c02's Sep 10 -> c02 ("date")
+// Never built, so the posts' timing is the estimate's.
+const ONSCREEN_POSTS = writeProject(
+ "onscreen-posts-fixture",
+ {
+ ...deckManifest("onscreen-posts-fixture", "The On-screen Posts Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence", date: "2024-09-10" },
+ { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" },
+ ]),
+ posts: [
+ {
+ id: "p-early", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-08-01T12:00:00.000Z", text: "Older than every clip in the cut.",
+ url: "https://bsky.app/profile/fixture.example/post/early",
+ },
+ {
+ id: "p-mid", platform: "bluesky", author: "Fixture Author", handle: "fixture.example",
+ date: "2024-09-05T09:30:00.000Z", text: "Two days after the first clip.\nA second line.",
+ url: "https://bsky.app/profile/fixture.example/post/mid",
+ },
+ {
+ id: "p-late", platform: "x", author: "Fixture Author", handle: "fixture",
+ date: "2024-09-12T18:00:00.000Z", text: "Two days after the second clip.",
+ url: "https://x.com/fixture/status/1",
+ },
+ ],
+ },
+);
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -1545,7 +1580,7 @@ ff([
// intermediates and are excluded by name.
mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true });
-for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD]) {
+for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD, ONSCREEN_POSTS]) {
mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true });
copyFileSync(
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
@@ -1666,5 +1701,6 @@ console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached —
console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`);
console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`);
console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`);
+console.log(` onscreen-posts-fixture (writable, deck on, three posts, unbuilt)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts
@@ -0,0 +1,264 @@
+import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { postWindows } from "umtool-report-to-video/deck";
+
+// ---------------------------------------------------------------------------
+// POSTS on the on-screen deck, as umtool edits them: the Posts table under the
+// titles, its override and hide, the writer behind it, and the posts region
+// in the live preview.
+//
+// One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and
+// a card, three posts whose dates put them on c01 ("first"), c01 ("date") and
+// c02 ("date"). Never built, so every timing is the estimate's. Each test puts
+// the posts back to automatic and shown through the route before it starts.
+//
+// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`, slice
+// P1 of plans/deck-posts.md). When it is there, the preview test asserts the
+// window's page reports `posts:ready`; when it is not, the route says so per
+// window and the test asserts that refusal instead, and annotates the run.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const FIXTURE = path.join(HERE, "..", ".e2e-song");
+const PROJECT = "reports/onscreen-posts-fixture";
+const DIR = path.join(FIXTURE, "reports", "onscreen-posts-fixture");
+const IDS = ["p-early", "p-mid", "p-late"];
+
+type Post = { id: string; attachTo?: string | null; hide?: boolean };
+type Manifest = { render: Record<string, unknown>; posts: Post[] };
+const readManifest = (): Manifest => JSON.parse(readFileSync(path.join(DIR, "video.manifest.json"), "utf8"));
+const post = (id: string) => readManifest().posts.find((p) => p.id === id)!;
+const enc = encodeURIComponent;
+
+type PostRow = {
+ id: string;
+ attachTo: string | null;
+ hide: boolean;
+ auto: { entryId: string; rule: string; label: string } | null;
+ effective: { entryId: string; rule: string } | null;
+ timing: { segment: string; appear: number } | null;
+};
+
+async function token(request: APIRequestContext): Promise<string> {
+ const j = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as { token: string };
+ return j.token;
+}
+
+async function putPosts(request: APIRequestContext, posts: Record<string, { attachTo?: string | null; hide?: boolean }>) {
+ const r = await request.put("/api/report/posts", { data: { project: PROJECT, posts, token: await token(request) } });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+async function rows(request: APIRequestContext): Promise<Record<string, PostRow>> {
+ const j = (await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as { posts: PostRow[] };
+ return Object.fromEntries(j.posts.map((p) => [p.id, p]));
+}
+
+const reset = (request: APIRequestContext) =>
+ putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }])));
+
+async function openSection(page: Page) {
+ await page.goto(`/browse/${PROJECT}`);
+ await expect(page.getByTestId("onscreen-section")).toHaveAttribute("data-onscreen", "on");
+ await expect(page.getByTestId("onscreen-post-row")).toHaveCount(3);
+}
+
+const postRow = (page: Page, id: string) => page.locator(`[data-testid="onscreen-post-row"][data-post="${id}"]`);
+
+test.beforeEach(async ({ request }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ !readManifest().render.fontRegular,
+ "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine",
+ );
+ await reset(request);
+});
+
+test("the posts table shows each post with the clip the date rule puts it on", async ({ page, request }) => {
+ // The route first: the automatic clip and the effective one, and a slot in
+ // the estimated schedule.
+ const r = await rows(request);
+ expect(r["p-early"].auto).toMatchObject({ entryId: "c01", rule: "first", label: "and because" });
+ expect(r["p-mid"].auto).toMatchObject({ entryId: "c01", rule: "date" });
+ expect(r["p-late"].auto).toMatchObject({ entryId: "c02", rule: "date", label: "another whole sentence" });
+ for (const id of IDS) expect(r[id].effective?.entryId).toBe(r[id].auto?.entryId);
+ expect(r["p-late"].timing?.segment).toBe("c02");
+
+ await openSection(page);
+ const late = postRow(page, "p-late");
+ await expect(late).toHaveAttribute("data-effective", "c02");
+ await expect(late.getByTestId("onscreen-post-platform")).toHaveText("x");
+ await expect(late.getByTestId("onscreen-post-handle")).toHaveText("@fixture");
+ await expect(late.getByTestId("onscreen-post-link")).toHaveAttribute("href", "https://x.com/fixture/status/1");
+ await expect(late.getByTestId("onscreen-post-text")).toHaveText("Two days after the second clip.");
+ await expect(late.getByTestId("onscreen-post-auto")).toHaveText("auto: c02 — another whole sentence");
+ await expect(late.getByTestId("onscreen-post-attach")).toHaveValue("");
+ await expect(late.getByTestId("onscreen-post-effective")).toContainText("automatic: c02 (2024-09-10)");
+ await expect(postRow(page, "p-early")).toHaveAttribute("data-effective", "c01");
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-auto")).toHaveText("auto: c01 — and because");
+ await expect(page.getByTestId("onscreen-posts-save")).toBeDisabled();
+});
+
+test("an override saves attachTo and moves the effective clip; auto deletes the key", async ({ page, request }) => {
+ await openSection(page);
+ const mid = postRow(page, "p-mid");
+ await mid.getByTestId("onscreen-post-attach").selectOption("c02");
+ await expect(mid).toHaveAttribute("data-dirty", "1");
+ await expect(mid).toHaveAttribute("data-effective", "c02");
+ await expect(mid.getByTestId("onscreen-post-effective")).toContainText("chosen: c02");
+ // Unsaved: nothing written yet.
+ expect("attachTo" in post("p-mid")).toBe(false);
+
+ const put = page.waitForRequest((q) => q.url().includes("/api/report/posts") && q.method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ // Only the post that changed, only the key that changed.
+ expect((await put).postDataJSON().posts).toEqual({ "p-mid": { attachTo: "c02" } });
+ await expect.poll(() => post("p-mid").attachTo).toBe("c02");
+ await expect(mid).toHaveAttribute("data-dirty", "0");
+ const r = await rows(request);
+ expect(r["p-mid"].effective).toMatchObject({ entryId: "c02", rule: "attachTo" });
+ // The automatic answer is still the date rule's.
+ expect(r["p-mid"].auto?.entryId).toBe("c01");
+ expect(r["p-mid"].timing?.segment).toBe("c02");
+
+ // Back to auto: the key goes, it is not left as null.
+ await page.reload();
+ await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02");
+ await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("");
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => "attachTo" in post("p-mid")).toBe(false);
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-effective", "c01");
+});
+
+test("hide persists across a reload, and showing it again deletes the key", async ({ page, request }) => {
+ await openSection(page);
+ const early = postRow(page, "p-early");
+ await early.getByTestId("onscreen-post-hide").check();
+ await expect(early).toHaveAttribute("data-hidden", "1");
+ await expect(early.getByTestId("onscreen-post-attach")).toBeDisabled();
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => post("p-early").hide).toBe(true);
+
+ const r = await rows(request);
+ expect(r["p-early"].effective).toBeNull();
+ // It still says where it WOULD ride.
+ expect(r["p-early"].auto?.entryId).toBe("c01");
+ expect(r["p-early"].timing).toBeNull();
+
+ await page.reload();
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-hide")).toBeChecked();
+ await expect(postRow(page, "p-early")).toHaveAttribute("data-dirty", "0");
+ await expect(postRow(page, "p-early").getByTestId("onscreen-post-effective")).toContainText("hidden");
+
+ await postRow(page, "p-early").getByTestId("onscreen-post-hide").uncheck();
+ await page.getByTestId("onscreen-posts-save").click();
+ await expect.poll(() => "hide" in post("p-early")).toBe(false);
+});
+
+test("a stale token is a 409; reloading keeps the edit and shows the other writer's", async ({ page, request }) => {
+ await openSection(page);
+
+ // Somebody else writes after this page read its token.
+ await putPosts(request, { "p-late": { hide: true } });
+
+ await postRow(page, "p-mid").getByTestId("onscreen-post-attach").selectOption("c02");
+ const refused = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ expect((await refused).status()).toBe(409);
+ await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this");
+ expect("attachTo" in post("p-mid")).toBe(false);
+ expect(post("p-late").hide).toBe(true);
+
+ await page.getByTestId("onscreen-reload").click();
+ await expect(page.getByTestId("onscreen-reload")).toHaveCount(0);
+ // The unsaved override survives...
+ await expect(postRow(page, "p-mid").getByTestId("onscreen-post-attach")).toHaveValue("c02");
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-dirty", "1");
+ // ...and a post nobody touched here shows what the other writer saved,
+ // rather than reading as an edit the next save would quietly revert.
+ await expect(postRow(page, "p-late").getByTestId("onscreen-post-hide")).toBeChecked();
+ await expect(postRow(page, "p-late")).toHaveAttribute("data-dirty", "0");
+
+ const saved = page.waitForResponse((r) => r.url().includes("/api/report/posts") && r.request().method() === "PUT");
+ await page.getByTestId("onscreen-posts-save").click();
+ expect((await saved).status()).toBe(200);
+ await expect.poll(() => post("p-mid").attachTo).toBe("c02");
+ expect(post("p-late").hide).toBe(true);
+});
+
+test("the writer refuses an unknown post and a clip that is not one, and writes nothing", async ({ request }) => {
+ const before = readFileSync(path.join(DIR, "video.manifest.json"), "utf8");
+ const unknown = await request.put("/api/report/posts", {
+ data: { project: PROJECT, posts: { "p-mid": { hide: true }, nope: { hide: true } }, token: await token(request) },
+ });
+ expect(unknown.status()).toBe(400);
+ expect(((await unknown.json()) as { error: string }).error).toContain("no post with id nope");
+ const card = await request.put("/api/report/posts", {
+ data: { project: PROJECT, posts: { "p-mid": { attachTo: "k01" } }, token: await token(request) },
+ });
+ expect(card.status()).toBe(400);
+ expect(((await card.json()) as { errors: string[] }).errors).toEqual([
+ 'posts[1].attachTo "k01" is not a clip in the timeline',
+ ]);
+ expect(readFileSync(path.join(DIR, "video.manifest.json"), "utf8")).toBe(before);
+});
+
+test("the preview composes the posts region per window and overlays it while the scrubber is inside", async ({
+ page,
+ request,
+}) => {
+ // The route: one window per clip that carries posts, the deck's own
+ // postWindows over the schedule it returns, at postsGeometry.
+ const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as {
+ schedule: Parameters<typeof postWindows>[0] & { total: number };
+ posts: {
+ geometry: { x: number; y: number; width: number; height: number };
+ windows: { segment: string; from: number; to: number; src?: string; error?: string }[];
+ };
+ };
+ expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule));
+ expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]);
+ expect(pv.posts.geometry.width).toBe(600);
+ const composed = pv.posts.windows.every((w) => w.src);
+ if (!composed) {
+ // compose-chrome has no posts region on this branch yet (P1). The route
+ // made the call and says why it failed, per window, beside a working deck.
+ test.info().annotations.push({ type: "pending", description: "posts region not in compose-chrome yet (P1)" });
+ for (const w of pv.posts.windows) expect(w.error).toMatch(/posts/);
+ }
+
+ // The bench's strip asks for no posts windows at all.
+ const bench = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT, posts: false } })
+ ).json()) as { posts: { windows: unknown[] } };
+ expect(bench.posts.windows).toEqual([]);
+
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ await expect(page.locator("[data-posts-window]")).toHaveCount(2);
+ // Outside every window: nothing over the footage. k01 starts after the last.
+ await page.locator('[data-seg-jump="k01"]').click();
+ await expect(page.getByTestId("onscreen-current")).toHaveText("k01");
+ await expect(page.getByTestId("onscreen-posts-preview")).toHaveCount(0);
+
+ await page.locator('[data-posts-window="c02"]').click();
+ const overlay = page.getByTestId("onscreen-posts-preview");
+ await expect(overlay).toHaveAttribute("data-segment", "c02");
+ await expect(postRow(page, "p-late")).toHaveAttribute("data-current", "1");
+ await expect(postRow(page, "p-mid")).toHaveAttribute("data-current", "0");
+ if (composed) {
+ await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 });
+ await expect(page.getByTestId("onscreen-posts-preview-iframe")).toHaveAttribute("src", /posts-preview-c02\/index\.html/);
+ } else {
+ await expect(overlay.getByTestId("onscreen-posts-preview-error")).toContainText("posts on c02: not composed");
+ }
+
+ // The overlay sits at the posts rect inside the 16:9 frame.
+ const frame = (await page.getByTestId("onscreen-preview").boundingBox())!;
+ const box = (await overlay.boundingBox())!;
+ const W = 1920;
+ expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01);
+ expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01);
+});