Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/e2e/onscreen-posts.spec.ts | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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 } }); });