commit 6bf33f953ef3ea1c1faf2f12263a25cb78b06ef5
parent 32f6707a84962ef3d7c583c974301c7237fae333
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 13:10:29 -0400
umtool e2e: the posts preview at the 4 s default with holds and the footage moving aside; make room off persists shift: false
onscreen-posts.spec.ts asserts the estimate's held timings, the scrubber's
held total, the window marks on its clock, the hatched hold on a carrying
segment, the backdrop's computed transform before, through and after c02's
move (none over k01), and that turning make room off writes shift: false,
survives a later save and puts the column back inside the footage box. Each
test resets the deck to its defaults first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 191 insertions(+), 13 deletions(-)
diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts
@@ -1,8 +1,8 @@
-import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
+import { test, expect, type APIRequestContext, type Locator, 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";
+import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck";
// ---------------------------------------------------------------------------
// POSTS on the on-screen deck, as umtool edits them: the Posts table under the
@@ -12,7 +12,15 @@ import { postWindows } from "umtool-report-to-video/deck";
// 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 back to automatic and shown, and the deck back to its defaults,
+// through the routes before it starts.
+//
+// With the defaults (posts.seconds 4, hold 2.5, shift on) the estimate is:
+// c01 0 → 5.5 3 s clip + a 2.5 s hold; p-early at 0, p-mid at 2.5
+// c02 5 → 10.5 3 s clip + a 2.5 s hold; p-late at 6
+// k01 10 → 13 the card
+// and the footage moves aside as each clip's first post appears (c01 at 0,
+// c02 at 6), with the posts column at the frame's right edge.
//
// The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the
// preview test asserts every window composes and its page reports
@@ -55,8 +63,54 @@ async function rows(request: APIRequestContext): Promise<Record<string, 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 }])));
+const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} };
+
+async function putChrome(request: APIRequestContext, chrome: unknown) {
+ const r = await request.put("/api/report/chrome", { data: { project: PROJECT, chrome, token: await token(request) } });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+const reset = async (request: APIRequestContext) => {
+ await putChrome(request, DECK_ON);
+ await putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }])));
+};
+
+type Rect = { x: number; y: number; width: number; height: number };
+type Preview = {
+ schedule: {
+ total: number;
+ segments: { id: string; start: number; duration: number; end: number; hold?: number }[];
+ posts: { id: string; segment: string; appear: number; out: [number, number] }[];
+ moves?: { segment: string; at: number; seconds: number; from: Rect; to: Rect }[];
+ };
+ posts: { geometry: Rect; windows: { segment: string; from: number; to: number; src?: string; error?: string }[] };
+};
+const previewOf = async (request: APIRequestContext, extra: Record<string, unknown> = {}) =>
+ (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT, ...extra } })).json()) as Preview;
+
+/** Put the scrubber at `t` (seconds) and wait for the clock to say so. */
+async function seek(page: Page, t: number) {
+ await page.getByTestId("onscreen-scrubber").fill(String(t));
+ await expect(page.getByTestId("onscreen-scrubber")).toHaveValue(String(t));
+}
+
+/** Fill a field the page may still be re-rendering from a load: retry until the value holds. */
+async function fillSure(input: Locator, value: string) {
+ await expect(async () => {
+ await input.fill(value);
+ await expect(input).toHaveValue(value, { timeout: 1000 });
+ }).toPass({ timeout: 15_000 });
+}
+
+/** The backdrop's computed transform as [scaleX, scaleY, translateX px, translateY px, frame width px], or null for none. */
+const backdropMatrix = (page: Page) =>
+ page.getByTestId("onscreen-backdrop-frame").evaluate((el) => {
+ const tr = getComputedStyle(el).transform;
+ if (!tr || tr === "none") return null;
+ const m = new DOMMatrixReadOnly(tr);
+ // offsetWidth: the frame's laid-out width, before the transform scales it.
+ return [m.a, m.d, m.e, m.f, (el as HTMLElement).offsetWidth];
+ });
async function openSection(page: Page) {
await page.goto(`/browse/${PROJECT}`);
@@ -210,16 +264,32 @@ test("the preview composes the posts region per window and overlays it while the
}) => {
// 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));
+ const pv = await previewOf(request);
+ expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule as Parameters<typeof postWindows>[0]));
expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]);
expect(pv.posts.geometry.width).toBe(600);
+
+ // The defaults' timing: 4 s per post, each carrying clip held 2.5 s, every
+ // start and the total measured with the holds.
+ expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([
+ ["c01", 0, 5.5, 2.5],
+ ["c02", 5, 5.5, 2.5],
+ ["k01", 10, 3, 0],
+ ]);
+ expect(pv.schedule.total).toBe(13);
+ expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.appear, p.out])).toEqual([
+ ["p-early", "c01", 0, [5, 5.5]],
+ ["p-mid", "c01", 2.5, [5, 5.5]],
+ ["p-late", "c02", 6, [10, 10.5]],
+ ]);
+ expect(pv.posts.windows.map(({ from, to }) => [from, to])).toEqual([[0, 5.5], [6, 10.5]]);
+ // The footage makes room: one move per carrying clip, and the column at the frame's edge.
+ const render = readManifest().render;
+ expect(pv.schedule.moves?.map((m) => [m.segment, m.at, m.seconds])).toEqual([["c01", 0, 0.6], ["c02", 6, 0.6]]);
+ expect(pv.schedule.moves?.[0].from).toEqual(deckGeometry(render).footage);
+ expect(pv.schedule.moves?.[0].to).toEqual(shiftedFootage(render));
+ expect(pv.posts.geometry).toEqual(postsGeometry(render));
+ expect(pv.posts.geometry.x).toBe(1920 - 24 - 600);
// Every window composes: compose-chrome draws the posts region.
for (const w of pv.posts.windows) {
expect(w.error).toBeUndefined();
@@ -235,6 +305,28 @@ test("the preview composes the posts region per window and overlays it while the
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);
+
+ // The scrubber runs the held length, and the window marks sit on its clock.
+ await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "13");
+ await expect(page.getByTestId("onscreen-time")).toContainText("/ 0:13.0");
+ for (const [seg, from, to] of [["c01", 0, 5.5], ["c02", 6, 10.5]] as const) {
+ const style = await page.locator(`[data-posts-window="${seg}"]`).evaluate((el) => [
+ parseFloat((el as HTMLElement).style.left),
+ parseFloat((el as HTMLElement).style.width),
+ ]);
+ expect(style[0]).toBeCloseTo((from / 13) * 100, 2);
+ expect(style[1]).toBeCloseTo(((to - from) / 13) * 100, 2);
+ }
+
+ // A held segment shows its hold: a hatched tail, hold/duration of its block.
+ await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(2);
+ await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("data-hold", "2.5");
+ await expect(page.locator('[data-seg-jump="k01"]')).toHaveAttribute("data-hold", "0");
+ await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("title", /held 2\.5 s for its posts/);
+ const block = (await page.locator('[data-seg-jump="c02"]').boundingBox())!;
+ const tail = (await page.locator('[data-seg-hold="c02"]').boundingBox())!;
+ expect(tail.width / block.width).toBeCloseTo(2.5 / 5.5, 1);
+ expect(Math.abs(tail.x + tail.width - (block.x + block.width))).toBeLessThan(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");
@@ -255,4 +347,90 @@ test("the preview composes the posts region per window and overlays it while the
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);
+
+ // The backdrop moves inside the window: the window mark put the scrubber at
+ // 8.25, past c02's move (6 → 6.6), so the footage is all the way aside...
+ const from = pv.schedule.moves![1].from;
+ const to = pv.schedule.moves![1].to;
+ const backdrop = page.getByTestId("onscreen-backdrop-frame");
+ await expect(backdrop).toHaveAttribute("data-move", "c02");
+ await expect(backdrop).toHaveAttribute("data-move-progress", "1");
+ const aside = (await backdropMatrix(page))!;
+ expect(aside[0]).toBeCloseTo(to.width / from.width, 3);
+ expect(aside[1]).toBeCloseTo(to.height / from.height, 3);
+ // ...its box's left edge where the build puts it (in frame pixels)...
+ expect((aside[2] / aside[4]) * W + aside[0] * from.x).toBeCloseTo(to.x, 0);
+ // ...half way through the move at its middle, on the smoothstep curve...
+ await seek(page, 6.3);
+ await expect(backdrop).toHaveAttribute("data-move-progress", "0.5");
+ const half = (await backdropMatrix(page))!;
+ expect(half[0]).toBeCloseTo((1 + to.width / from.width) / 2, 3);
+ // ...in its box before the move...
+ await seek(page, 5.9);
+ await expect(page.getByTestId("onscreen-current")).toHaveText("c02");
+ await expect(backdrop).toHaveAttribute("data-move-progress", "0");
+ expect((await backdropMatrix(page))?.[0] ?? 1).toBeCloseTo(1, 3);
+ // ...and not moved at all over a segment that carries no posts.
+ await seek(page, 12);
+ await expect(page.getByTestId("onscreen-current")).toHaveText("k01");
+ await expect(backdrop).toHaveAttribute("data-move", "");
+ expect(await backdropMatrix(page)).toBeNull();
+});
+
+test("make room off in the settings writes shift: false, the form keeps it, and the column goes back inside the footage", async ({
+ page,
+ request,
+}) => {
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ const shift = page.getByTestId("onscreen-setting-posts.shift");
+ await expect(shift).toBeChecked();
+ await expect(page.getByTestId("onscreen-setting-posts.hold")).toHaveValue("2.5");
+ await expect(page.getByTestId("onscreen-setting-posts.seconds")).toHaveValue("4");
+ await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86");
+ await expect(page.getByTestId("onscreen-setting-posts.shift.seconds")).toHaveValue("0.6");
+
+ await shift.uncheck();
+ // Off has no scale or move to set.
+ await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveCount(0);
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { shift: false } });
+
+ // The schedule has no moves and the column is inside the footage box again.
+ const render = readManifest().render;
+ const pv = await previewOf(request);
+ expect(pv.schedule.moves).toBeUndefined();
+ expect(pv.posts.geometry).toEqual(postsGeometry(render));
+ const f = deckGeometry(render).footage;
+ expect(pv.posts.geometry.x).toBe(f.x + f.width - 24 - 600);
+
+ // Saving another setting keeps it: the form never drops `shift: false`.
+ await page.reload();
+ await expect(page.getByTestId("onscreen-setting-posts.shift")).not.toBeChecked();
+ await fillSure(page.getByTestId("onscreen-setting-posts.hold"), "1.5");
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({
+ posts: { hold: 1.5, shift: false },
+ });
+
+ // In the live preview: the overlay inside the footage box, and the backdrop where it always was.
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ await page.locator('[data-posts-window="c02"]').click();
+ const overlay = page.getByTestId("onscreen-posts-preview");
+ await expect(overlay).toHaveAttribute("data-segment", "c02");
+ await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 });
+ const frame = (await page.getByTestId("onscreen-preview").boundingBox())!;
+ const box = (await overlay.boundingBox())!;
+ const W = 1920;
+ expect((box.x - frame.x) / frame.width).toBeGreaterThanOrEqual(f.x / W - 0.005);
+ expect((box.x + box.width - frame.x) / frame.width).toBeLessThanOrEqual((f.x + f.width) / W + 0.005);
+ expect(Math.abs((box.x - frame.x) / frame.width - (f.x + f.width - 24 - 600) / W)).toBeLessThan(0.01);
+ await expect(page.getByTestId("onscreen-backdrop-frame")).toHaveAttribute("data-move", "");
+ expect(await backdropMatrix(page)).toBeNull();
+
+ // On again: the default, so nothing under posts but the hold.
+ await page.getByTestId("onscreen-setting-posts.shift").check();
+ await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86");
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5 } });
});