import { test, expect } from "@playwright/test"; import { caps, NO_SONG_DATA } from "./capabilities"; // THE WHOLE FILE IS ONE CLIP CARD, and there is no card without a queue: /sort // serves candidates, and make-fixture copies one only when its wav is present. // With no song bulk data every assertion here is about an element that was // never rendered. test.skip(!caps.song, NO_SONG_DATA); // What these cover, in the order the plan asks for them: // * the card renders and the window is UNBOUNDED (widening fetches more audio) // * a tall card SCROLLS rather than sliding under the footer // * the verdict buttons are TWO LEVELS, not a growing stack // * the word selector offers curated words, this clip's neighbours, and takes // a custom one that persists for every later clip test.beforeEach(async ({ page }) => { await page.goto("/sort"); await expect(page.getByRole("link", { name: "sort", exact: true })).toBeVisible(); }); test("a clip opens with a window and a waveform", async ({ page }) => { // By testid, not by tag: there are three canvases on the card now (the // waveform and the pitch rail in both orientations), and a bare // locator("canvas") is a strict-mode violation rather than an assertion. await expect(page.getByTestId("waveform")).toBeVisible(); await expect(page.getByText(/selection\s/)).toBeVisible(); // The default view is the selection plus a second each side. await expect(page.getByText(/view \d/)).toBeVisible(); }); test("the pitch rail reports the corpus it is drawing against", async ({ page }) => { // The rail is an INSTRUMENT, so what matters is that it is fed the real // distribution rather than that pixels appeared. The gate is the line // arrange-poly.mjs actually cuts flagged sources on; median and p95 come from // the ACCEPTED palette, which is the set a clip is really judged against -- // measured over all candidates instead, p95 moves 128 -> 152 and an unusual // clip would look ordinary. const rail = page.getByTestId("pitch-rail-flat"); await expect(rail).toBeAttached(); await expect(rail).toHaveAttribute("data-gate", "115"); // The fixture seeds a synthetic accepted palette shaped like the real one, so // these are real numbers rather than a placeholder. p95 must sit ABOVE the // median and ABOVE the gate -- that ordering is the whole reason the rail // reads as an instrument instead of a decoration. const median = await expect .poll(async () => Number(await rail.getAttribute("data-median"))) .toBeGreaterThan(90) .then(async () => Number(await rail.getAttribute("data-median"))); const p95 = Number(await rail.getAttribute("data-p95")); expect(median).toBeLessThan(115); expect(p95).toBeGreaterThan(median); }); test("evidence is measured on the window on screen", async ({ request }) => { const list = await (await request.get("/api/clips?mode=sort&limit=1")).json(); const key: string = list.clips[0].key; const at = key.lastIndexOf("@"); const start = Number(key.slice(at + 1)); const r = await request.get( `/api/clip/${encodeURIComponent(key)}/evidence?from=${start}&to=${(start + 0.25).toFixed(3)}`, ); expect(r.ok()).toBe(true); const j = await r.json(); // Spectral flatness is scored NOWHERE else in the pipeline, and render-poly // normalises every clip to a target level -- so the hissiest clip gets the // largest gain and its noise is amplified into the mix. expect(j.flatness).toBeGreaterThan(0); expect(j.flatMax).toBe(0.055); expect(Array.isArray(j.wordsInside)).toBe(true); // A source's pitch SPREAD in semitones, not a cluster count: gap-based // clustering reports "one clump" for the documented two-speaker source. expect(j.source.spread).toBeGreaterThanOrEqual(0); }); test("a word the window already contains is read off the ASR", async ({ page, request }) => { // `tBHm09OttbQ@94.17` is the case: token "um", and the ASR word "and" // (94.08-94.32) sitting inside the default window (94.10-94.723). "and um" // was derivable from data already on the card, and the selector still asked. const list = await (await request.get("/api/clips?mode=sort&limit=200")).json(); let target: string | null = null; let expectWord = ""; for (const c of (list.clips as { key: string }[]).slice(0, 40)) { const d = await (await request.get(`/api/clip/${encodeURIComponent(c.key)}`)).json(); if (!d?.words) continue; const inside = (d.words as { start: number; end: number; w: string }[]).filter( (w) => w.end > d.selStart + 0.03 && w.start < d.selEnd - 0.03 && !/^(?:u+[mh]+|e+r+m?|h+m+|m+h*m+)$/i.test(w.w.replace(/[^a-z]/gi, "")), ); if (inside.length === 1) { target = c.key; expectWord = inside[0].w.toLowerCase().replace(/[^a-z'-]/g, ""); break; } } test.skip(!target, "no fixture clip has exactly one real word in its default window"); await page.goto(`/sort?only=${encodeURIComponent(target!)}`); const chip = page.getByText(/word from ASR:/); await expect(chip).toBeVisible(); await expect(chip).toContainText(expectWord); }); test("the window is unbounded: widening fetches more audio", async ({ page }) => { const view = page.getByText(/view [\d.]+s of/); await expect(view).toBeVisible(); const before = await view.textContent(); const span = Number(before!.match(/view ([\d.]+)s/)![1]); // A request must actually go out -- the point is that the audio is served, // not that a label changed. const audio = page.waitForResponse((r) => r.url().includes("/audio?") && r.ok()); await page.getByRole("button", { name: /\+1s right/ }).click(); await audio; await expect .poll(async () => Number((await view.textContent())!.match(/view ([\d.]+)s/)![1])) .toBeGreaterThan(span); }); test("verdict buttons are two levels, and Esc backs out", async ({ page }) => { const verdicts = page.getByRole("button", { name: /not an um/ }); await expect(verdicts).toBeVisible(); await verdicts.click(); // Level 2 REPLACES level 1 -- this is what stops the card growing. await expect(page.getByRole("button", { name: /not an um/ })).toHaveCount(0); await expect(page.getByRole("button", { name: "← back" })).toBeVisible(); await expect(page.getByRole("button", { name: /a real word, not a filler/ })).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: /not an um/ })).toBeVisible(); await expect(page.getByRole("button", { name: "← back" })).toHaveCount(0); }); test("a tall card scrolls instead of hiding under the footer", async ({ page }) => { await page.setViewportSize({ width: 900, height: 380 }); // Open level 2, which is the tallest the card gets. await page.getByRole("button", { name: /not an um/ }).click(); const main = page.locator("main.deck-main"); await expect(main).toBeVisible(); // The scroll container must actually be scrollable, and the last button must // be reachable through it -- not clipped away at both ends by place-items. const scrollable = await main.evaluate((el) => el.scrollHeight > el.clientHeight + 2); expect(scrollable).toBe(true); // The LAST reason button, whichever list this verdict opened -- naming the // reasons here would pass or fail on which of the two lists was showing. const last = page.locator("[data-code]").last(); await expect(last).toBeAttached(); await last.scrollIntoViewIfNeeded(); await expect(last).toBeInViewport(); }); test("the word selector offers curated words, neighbours, and takes a custom one", async ({ page, }) => { await page.getByTitle(/Choose the word/).click(); await expect(page.getByPlaceholder("type to filter, Enter picks")).toBeVisible(); await expect(page.getByText("words", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "mm-hmm", exact: true })).toBeVisible(); // A curated pick. await page.getByRole("button", { name: "umm", exact: true }).click(); await expect(page.getByRole("button", { name: /^umm/ })).toBeVisible(); // A custom phrase, which must survive a reload and be offered on later clips. await page.getByTitle(/Choose the word/).click(); await page.getByPlaceholder("type to filter, Enter picks").fill("wobbledy uh"); const post = page.waitForResponse((r) => r.url().includes("/api/vocab") && r.request().method() === "POST"); await page.getByRole("button", { name: /add.*wobbledy uh/ }).click(); await post; await page.reload(); await page.getByTitle(/Choose the word/).click(); await expect(page.getByText("yours", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "wobbledy uh", exact: true })).toBeVisible(); }); test("judging a clip writes it through and does not offer it again", async ({ page, request }) => { const before = await (await request.get("/api/clips?mode=sort&limit=50")).json(); expect(before.clips.length).toBeGreaterThan(0); const first = before.clips[0].key; // The buttons are disabled until the clip's window has arrived -- a verdict // before then has nothing to attach a window to. const good = page.locator('[data-verdict="1"]'); await expect(good).toBeEnabled(); await good.click(); await expect .poll(async () => (await (await request.get("/api/clips?mode=sort&limit=50")).json()).counts.accepted) .toBe(before.counts.accepted + 1); const after = await (await request.get("/api/clips?mode=sort&limit=50")).json(); expect(after.clips.map((c: { key: string }) => c.key)).not.toContain(first); });