import { test, expect } from "@playwright/test"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // The claim bench, and the two decision kinds that drive it. // // The fixture ledger is built so every answer here is exact: // // k01 is fully adjudicated and pinned to c01 — the settled case. // k02 is adjudicated but `valueKind: "derived"`, so `not_his_number` must // fire on it and it must stay OUT of the stated series. // k03 carries none of the six fields, so it is the one blocking row. // // Every test in this file writes, so it uses bench-fixture rather than // report-fixture — the same split the clip bench already keeps. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); const FIXTURE = path.join(UMTOOL, ".e2e-song"); const PROJECT = "reports/bench-fixture"; const MANIFEST = path.join(FIXTURE, "reports", "bench-fixture", "video.manifest.json"); const claimPage = (id: string) => `/browse/${PROJECT}/claim/${id}`; type Claim = { id: string; scope?: string; scopeBasis?: string; scopeConfidence?: string; population?: string; valueKind?: string; flags?: string[]; roles?: Array<{ role: string; count: number; verbatim: string }>; }; const readClaim = (id: string): Claim => { const m = JSON.parse(readFileSync(MANIFEST, "utf8")) as { ledger: Claim[] }; return m.ledger.find((c) => c.id === id)!; }; const tokenFor = async ( request: { get: (u: string) => Promise<{ json: () => Promise }> }, claim: string, ) => { const r = await request.get(`/api/report/claim?project=${encodeURIComponent(PROJECT)}&claim=${claim}`); return (await r.json()) as { token: string; gaps: string[]; cues: unknown[]; at: number }; }; test("an unadjudicated claim is one BLOCKING row, naming the fields it lacks", async ({ page }) => { await page.goto("/browse/decisions"); const row = page.getByText(/no ruling on/).first(); await expect(row).toBeVisible(); // All six, because nobody has touched k03. await expect(row).toContainText("scope"); await expect(row).toContainText("population"); await expect(row).toContainText("valueKind"); }); test("the claim page opens on the quote inside its context, not on the quote alone", async ({ page }) => { await page.goto(claimPage("k01")); await expect(page.locator("[data-claim='k01']")).toBeVisible(); const ctx = page.getByTestId("claim-context"); await expect(ctx).toBeVisible(); // The cited cue is marked INSIDE the paragraph — that mark is the whole // point, because it is what shows the quote had someone else talking round it. await expect(ctx.locator("[data-cited='1']").first()).toBeVisible(); }); test("an adjudicated claim shows its ruling, an unadjudicated one shows the gap", async ({ page }) => { await page.goto(claimPage("k01")); await expect(page.getByText("adjudicated", { exact: true })).toBeVisible(); await expect(page.getByTestId("scope-media")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("valuekind-uttered")).toHaveAttribute("aria-pressed", "true"); await page.goto(claimPage("k03")); await expect(page.getByText(/unadjudicated — missing/)).toBeVisible(); await expect(page.getByTestId("scope-media")).toHaveAttribute("aria-pressed", "false"); }); test("a ruling round-trips to the ledger", async ({ page }) => { await page.goto(claimPage("k03")); await page.getByTestId("scope-coffee").click(); await page.getByTestId("confidence-read").click(); await page.getByTestId("population-contractor").click(); await page.getByTestId("valuekind-synthetic").click(); await page.getByTestId("claim-basis").fill("the phrase that settles it"); await page.getByTestId("claim-flags").fill("a hand-written flag"); await page.getByTestId("claim-save").click(); await expect(page.getByTestId("claim-note")).toContainText("saved"); const c = readClaim("k03"); expect(c.scope).toBe("coffee"); expect(c.scopeConfidence).toBe("read"); expect(c.population).toBe("contractor"); expect(c.valueKind).toBe("synthetic"); expect(c.scopeBasis).toBe("the phrase that settles it"); expect(c.flags).toEqual(["a hand-written flag"]); }); test("a blank scopeBasis is refused — an adjudication with no basis is an opinion", async ({ request }) => { const { token } = await tokenFor(request, "k01"); const r = await request.put("/api/report/claim", { data: { project: PROJECT, claim: "k01", token, scopeBasis: " " }, }); expect(r.status()).toBe(400); expect(await r.text()).toContain("scopeBasis"); }); test("a value outside the vocabulary is refused, not coerced", async ({ request }) => { const { token } = await tokenFor(request, "k01"); const r = await request.put("/api/report/claim", { data: { project: PROJECT, claim: "k01", token, population: "staff" }, }); expect(r.status()).toBe(400); expect(await r.text()).toContain("population"); // …and the stored ruling is untouched. expect(readClaim("k01").population).toBe("employees"); }); test("a stale token is a 409, never a silent overwrite", async ({ request }) => { const r = await request.put("/api/report/claim", { data: { project: PROJECT, claim: "k01", token: "0", scope: "coffee" }, }); expect(r.status()).toBe(409); expect(readClaim("k01").scope).toBe("media"); }); test("a fired predicate is an OPEN row, in plain words", async ({ page }) => { await page.goto("/browse/decisions"); // k02 is `derived`, so the sum is ours and the inbox has to say so. await expect(page.getByText(/our sum, not his figure/)).toBeVisible(); }); test("the context read is ±90s around the cite, not the clip's window", async ({ request }) => { const d = await tokenFor(request, "k01"); expect(d.at).toBe(3); expect(Array.isArray(d.cues)).toBe(true); expect(d.gaps).toEqual([]); }); // --------------------------------------------------------------------------- // The roster — the seventh field, and the only optional one. // --------------------------------------------------------------------------- test("a roster round-trips, and is not part of the gate", async ({ page }) => { await page.goto(claimPage("k01")); await page.getByTestId("claim-roles").fill( "2 | video editor | two video editors\n1 | graphics designer | a graphics designer", ); // The preview is what the rail will draw, so it is worth seeing before saving. await expect(page.getByText("reads as “2 editors · 1 designer”")).toBeVisible(); await page.getByTestId("claim-save").click(); await expect(page.getByTestId("claim-note")).toContainText("saved"); expect(readClaim("k01").roles).toEqual([ { count: 2, role: "video editor", verbatim: "two video editors" }, { count: 1, role: "graphics designer", verbatim: "a graphics designer" }, ]); // …and the claim was already adjudicated without one, which is the point: // `roles` is not one of the six. await expect(page.getByText("adjudicated", { exact: true })).toBeVisible(); }); test("a roster line with no verbatim cannot be saved", async ({ page }) => { await page.goto(claimPage("k02")); await page.getByTestId("claim-roles").fill("2 | video editor"); await expect(page.getByText(/needs count \| role \| his words/)).toBeVisible(); await expect(page.getByTestId("claim-save")).toBeDisabled(); }); test("a malformed roster is refused by the API too, not just by the page", async ({ request }) => { const { token } = await tokenFor(request, "k02"); const r = await request.put("/api/report/claim", { data: { project: PROJECT, claim: "k02", token, roles: [{ role: "", count: 2, verbatim: "x" }] }, }); expect(r.status()).toBe(400); expect(await r.text()).toContain("roles"); expect(readClaim("k02").roles).toBeUndefined(); });