import { test, expect } from "@playwright/test"; import { caps, NO_SONG_DATA } from "./capabilities"; import { readFileSync } from "node:fs"; import path from "node:path"; // The six deck tools, against the `deck` song make-fixture.mjs builds for them. // // deck is isolated ON PURPOSE: browse.spec promotes one of alpha's variants, // writes notes and overwrites alpha's spec.json, and it writes a note on a cut // slot beta never built. Neither song can carry an assertion of the form // "exactly these decisions and nothing else" once another spec has been through // it, and that assertion is the one that keeps the inbox honest. type Decision = { kind: string; project: string; target: string; severity: "blocking" | "open" | "info"; href: string; why: string; }; const decisions = async ( request: import("@playwright/test").APIRequestContext, project: string, ): Promise => { const r = await request.get(`/api/browse/decisions?project=${project}`); expect(r.ok()).toBe(true); return (await r.json()).items as Decision[]; }; // --------------------------------------------------------------------------- // 1. The decisions inbox. // --------------------------------------------------------------------------- // A STATIC segment inside app/browse/[song]/'s territory. When this regresses // the page still renders perfectly on its own and only the URL stops working, // so it is worth an assertion of its own. test("/browse/decisions is not swallowed by the [song] route", async ({ page }) => { const res = await page.goto("/browse/decisions"); expect(res?.status()).toBe(200); await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toContainText("decisions"); // If the catch-all had swallowed it, this would be a 404 for a project called // "decisions". A decision's `project` is a PATH now, so the suffix identifies // the song without hard-coding how deep the fixture nests it. await expect(page.locator("[data-project$='/deck']")).toBeVisible(); }); test("deck's open decisions are exactly the three that were seeded", async ({ request }) => { const waiting = (await decisions(request, "deck")).filter((d) => d.severity !== "info"); expect( waiting.map((d) => `${d.severity} ${d.kind} ${d.target}`).sort(), ).toEqual([ "blocking spec-problem background", "open stale-recipe wide.mp4", "open unjudged-variant variants/wide-quiet.mp4", ]); }); // The assertion that keeps "unjudged" meaning something: wide-judged.mp4 is a // live variant with a verdict, and it must not be in the list at all. test("a variant that has been judged is not an open decision", async ({ request }) => { const all = await decisions(request, "deck"); expect(all.map((d) => d.target)).not.toContain("variants/wide-judged.mp4"); }); // Severity is earned. deck has no vertical and nothing says it ever should -- // no variant named for it, no note on the slot -- so it is information, not // work. An inbox that called this blocking would be an inbox nobody opens. test("a missing cut nobody asked for is information, not a decision", async ({ request }) => { const missing = (await decisions(request, "deck")).filter((d) => d.kind === "missing-cut"); expect(missing.length).toBe(3); expect(missing.every((d) => d.severity === "info")).toBe(true); }); test("the paste carries the same decisions as the page", async ({ request }) => { const md = await (await request.get("/api/browse/context?song=deck")).text(); expect(md).toContain("## Open decisions"); expect(md).toContain("stale-recipe"); }); // --------------------------------------------------------------------------- // 3. Loudness. This runs before anything measures, because "the page fires zero // ffmpeg" is only checkable once. // --------------------------------------------------------------------------- test("nothing is measured until something asks", async ({ page, request }) => { // Visiting the cut page must not measure: a decode per rendition on load is // the trap listSongs() avoids at a lighter weight. await page.goto("/browse/deck/wide"); await expect(page.locator("[data-loudness='wide.mp4']")).toHaveAttribute("data-measured", "0"); const j = await (await request.get("/api/browse/loudness?song=deck")).json(); expect(j.rows.length).toBeGreaterThan(0); expect(j.rows.every((r: { loudness: unknown }) => r.loudness === null)).toBe(true); // The target comes off spec.json rather than a constant in the route. expect(j.target).toEqual({ lufs: -14, truePeak: -1 }); }); test("a 10 dB difference measures as 10 LU", async ({ request }) => { const measure = async (rel: string) => { const r = await request.post("/api/browse/loudness", { data: { song: "deck", rel } }); expect(r.ok(), `measuring ${rel}`).toBe(true); return (await r.json()).loudness as { integrated: number; truePeak: number | null }; }; const loud = await measure("wide.mp4"); const quiet = await measure("variants/wide-quiet.mp4"); // A DELTA has a true answer; an absolute LUFS assertion would be brittle // across ffmpeg builds and encoder settings. expect(loud.integrated - quiet.integrated).toBeGreaterThan(9.7); expect(loud.integrated - quiet.integrated).toBeLessThan(10.3); expect(loud.truePeak).not.toBeNull(); // And it is cached, so the second read costs nothing. const cached = await (await request.get("/api/browse/loudness?song=deck&rel=wide.mp4")).json(); expect(cached.rows[0].loudness.integrated).toBeCloseTo(loud.integrated, 5); }); // --------------------------------------------------------------------------- // 2. Blind A/B. // --------------------------------------------------------------------------- test("a blind pairing names neither file, anywhere", async ({ page }) => { await page.goto("/browse/deck/wide"); await page.locator("[data-pick='variants/wide-quiet.mp4']").click(); await page.locator("[data-blind]").click(); const left = page.locator("video").first(); await expect(left).toHaveAttribute("src", /\/api\/browse\/blind\?t=/); await expect(page.locator("video").nth(1)).toHaveAttribute("src", /\/api\/browse\/blind\?t=/); // The two panes must not be the same token, or there is nothing to compare. const a = await page.locator("video").first().getAttribute("src"); const b = await page.locator("video").nth(1).getAttribute("src"); expect(a).not.toBe(b); // WHICH TWO FILES are being compared is not the secret -- an A/B over a // shipped cut and a named variant could not be set up without knowing them, // and the picker still says which variant is in play. WHICH PANE IS WHICH is // the secret, so nothing on a pane may identify it: await expect(page.locator("[data-rel]")).toHaveCount(0); // No native controls, which draw the duration -- and two files of visibly // different length are not blind. await expect(page.locator("video[controls]")).toHaveCount(0); // No src that names a file, in the DOM or the network panel. await expect(page.locator("video[src*='/api/mix/media']")).toHaveCount(0); await expect(page.locator("[data-revealed]")).toHaveCount(0); // And no byte size or duration beside either pane. await expect(page.getByText("unlabelled").first()).toBeVisible(); }); test("deciding records the shown sides, reveals them, and writes no verdict", async ({ page, request, }) => { await page.goto("/browse/deck/wide"); await page.locator("[data-pick='variants/wide-quiet.mp4']").click(); await page.locator("[data-blind]").click(); await expect(page.locator("video").first()).toHaveAttribute("src", /blind\?t=/); await page.locator("[data-choose=left]").click(); // Reveal happens only after the call is recorded. await expect(page.locator("[data-revealed]").first()).toBeVisible(); const j = await (await request.get("/api/browse/compare?song=deck&cut=wide")).json(); expect(j.compares.length).toBeGreaterThan(0); const last = j.compares[j.compares.length - 1]; expect(last.blind).toBe(true); expect([last.left, last.right].sort()).toEqual(["variants/wide-quiet.mp4", "wide.mp4"]); expect(["left", "right", "tie"]).toContain(last.winner); // A compare is one dated observation; a verdict is a standing judgement. // Recording the first must not write the second, or five casual clicks // silently overwrite a considered keep. const open = await decisions(request, "deck"); expect(open.map((d) => d.target)).toContain("variants/wide-quiet.mp4"); }); test("a media token that was never minted serves nothing", async ({ request }) => { const r = await request.get("/api/browse/blind?t=deadbeef"); expect(r.status()).toBe(404); }); // --------------------------------------------------------------------------- // 4. Plan diff. // --------------------------------------------------------------------------- test("the diff reports the seeded edits exactly", async ({ request }) => { // BOTH PLANS ARE CANDIDATE-DERIVED. make-fixture writes them from the first // COPIED cand2 entry, and it copies one only when its wav is present — so // with no song bulk data there is nothing to diff, and the seeded edits this // asserts on never existed. test.skip(!caps.song, NO_SONG_DATA); const r = await request.get( "/api/browse/plandiff?song=alpha&a=alpha.plan.json&b=alpha-v2.plan.json", ); expect(r.ok()).toBe(true); const d = await r.json(); const melody = d.voices.find((v: { voice: string }) => v.voice === "melody"); // One slot re-sung, one note dropped, one added, two untouched. expect(melody).toMatchObject({ same: 2, swapped: 1, moved: 0, added: 1, removed: 1, only: null }); // A voice present in only one plan is a whole-voice event, not 2 additions // scattered through the list. const bass = d.voices.find((v: { voice: string }) => v.voice === "bass"); expect(bass).toMatchObject({ only: "b", added: 2 }); expect(d.deltas[0].kind).toBe("swapped"); expect(d.summary).toContain("1 re-sung"); }); // readProvenance FALLS BACK to clips.csv for a name it does not know, so a typo // would otherwise diff a plan against a CSV and label it with what was typed. test("a plan name that is not in plan/ is a 404, not a silent fallback", async ({ request }) => { const r = await request.get("/api/browse/plandiff?song=alpha&a=alpha.plan.json&b=nope.json"); expect(r.status()).toBe(404); }); // --------------------------------------------------------------------------- // 5. Thumbnails. // --------------------------------------------------------------------------- // The bug this fixes: the index rendered `alpha-a-auto.jpg` off the filename // convention while thumb-accepted.json named alpha-c.jpg -- so the page whose // job is to say what was decided was showing a picture nobody chose. test("the index serves the ACCEPTED cover, not the filename guess", async ({ request }) => { const r = await request.get("/api/browse/poster?song=alpha"); expect(r.ok()).toBe(true); expect(r.headers()["x-poster"]).toBe("thumb"); // Compared by BYTES, because that is the only thing that distinguishes the // two: the accepted alpha-c.jpg and the convention's alpha-a-auto.jpg are // both valid covers for this song, and only one of them was chosen. const accepted = await (await request.get("/api/mix/media?path=thumbs/alpha-c.jpg")).body(); const guess = await (await request.get("/api/mix/media?path=thumbs/alpha-a-auto.jpg")).body(); const served = await r.body(); expect(Buffer.compare(served, accepted)).toBe(0); expect(Buffer.compare(served, guess)).not.toBe(0); }); test("accepting a cover that reuses a face is refused, and names the clash", async ({ request }) => { const before = await (await request.get("/api/browse/thumbs?song=alpha")).json(); // The shared corner is READ OUT OF THE FIXTURE, not spelled here. It used to // be the literal `v1`, which stopped being true the moment make-fixture began // deriving its corners from real episodes so /browse/faces would have frames // to draw -- and the test then failed while the mechanism it checks was // working perfectly. What matters is that the refusal NAMES the episode, not // which episode the fixture happened to pick. const manifest = JSON.parse( readFileSync(path.join(process.cwd(), ".e2e-song", "code", "thumb-manifest.json"), "utf8"), ) as { thumbs: Record }; const inC = new Set((manifest.thumbs["alpha-c"].corners ?? []).map((c) => c.video)); const shared = (manifest.thumbs["alpha-d"].corners ?? []).map((c) => c.video).find((v) => inC.has(v)); expect(shared, "the fixture's alpha-d no longer shares a corner with alpha-c").toBeTruthy(); const r = await request.post("/api/browse/thumbs", { data: { song: "alpha", name: "alpha-d" } }); expect(r.status()).toBe(409); const body = await r.json(); expect(body.error).toContain(shared); expect(body.error).toContain("alpha-c"); // Nothing was written: the refusal happens before any job starts. const after = await (await request.get("/api/browse/thumbs?song=alpha")).json(); expect(after.accepted).toEqual(before.accepted); expect(after.check).toEqual(before.check); }); test("accepting a disjoint cover runs the CLI and holds the rule", async ({ request }) => { const r = await request.post("/api/browse/thumbs", { data: { song: "alpha", name: "alpha-b" } }); expect(r.ok(), await r.text()).toBe(true); await expect .poll(async () => (await (await request.get("/api/browse/thumbs?song=alpha")).json()).check.covers, { timeout: 10_000, }) .toBe(2); const view = await (await request.get("/api/browse/thumbs?song=alpha")).json(); expect(view.check.ok).toBe(true); expect(view.check.videos).toBe(8); // The CLI records `out` RELATIVE to the reports tree. The fixture's run log // holds the absolute form, and the real thumb-accepted.json is tracked: an // accept from this page must not write a machine's path back into it. const onDisk = JSON.parse( readFileSync(path.join(process.cwd(), ".e2e-song", "code", "thumb-accepted.json"), "utf8"), ) as { thumbs: Record }; expect(onDisk.thumbs["alpha-b"].out).toBe(path.join("thumbs", "alpha-b.jpg")); }); // --------------------------------------------------------------------------- // 6. Source concentration. // --------------------------------------------------------------------------- test("the sources page rolls every plan up by episode", async ({ page }) => { // "Every plan" is the candidate-derived ones — see above. test.skip(!caps.song, NO_SONG_DATA); await page.goto("/browse/sources"); await expect(page.locator("[data-source]").first()).toBeVisible(); // Placements and clips are different units and are never summed. The table is // sorted by placements, so the first cell is the busiest episode's count. const placements = Number( await page.locator("[data-placements]").first().getAttribute("data-placements"), ); expect(placements).toBeGreaterThan(0); });