import { expect, test, type Page } from "@playwright/test"; import { installChartRoutes, showAll, urlParams } from "./helpers"; import { over, painted, rgbOf } from "../../common/testing/chartPixels"; // Charts are now a VIEW MODE of the search page: a "Results | Chart" toggle // plots the current search/filters as a single chart. Stats, summaries and // transcripts are route-mocked (installChartRoutes); the page drives real // aggregation, the search engine, URL sharing and recharts rendering. test.use({ permissions: ["clipboard-read", "clipboard-write"] }); async function runSearch(page: Page, term: string) { const box = page.getByPlaceholder("Search transcripts..."); await expect(box).toBeVisible(); await box.fill(term); await box.press("Enter"); await expect(page).toHaveURL(/[?&]qt=/); } // The chart card's colour: what is behind the plot. function cardColour(page: Page) { return page.locator(".recharts-surface").first().evaluate((el) => { for (let n: Element | null = el; n; n = n.parentElement) { const bg = getComputedStyle(n).backgroundColor; if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg; } return ""; }); } function chartTab(page: Page) { return page.getByTestId("view-toggle").getByRole("button", { name: "Chart" }); } // Switch to the chart view, absorbing a pre-hydration lost click. Playwright's // click() waits for actionability but not React hydration, so under dev-server // compile load the first click can land before the toggle's onClick is attached // and be a no-op — the view never switches and .recharts-surface never mounts. // chart-options renders immediately once view === "chart", so it confirms the // switch took; the Chart button is an idempotent setter (setView("chart")), so // re-clicking is safe. async function openChart(page: Page) { await expect(async () => { await chartTab(page).click(); await expect(page.getByTestId("chart-options")).toBeVisible({ timeout: 1_000, }); }).toPass({ timeout: 20_000 }); } test.describe("charts (search view mode)", () => { test.beforeEach(async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); await installChartRoutes(page); }); test("Chart toggle plots the current search", async ({ page }) => { await page.goto("/"); await runSearch(page, "alpha"); await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); // "alpha" hits once per fixture transcript → 3 videos, 3 hits. await expect(page.getByTestId("chart-caption")).toContainText( "3 videos · 3 hits", { timeout: 20_000 }, ); // The chart view is reflected in the URL live (shareable like a search). const params = await urlParams(page); expect(params.get("view")).toBe("chart"); expect(params.get("cs")).toBeTruthy(); }); test("Chart toggle with no query plots metadata", async ({ page }) => { await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); // Three fixture videos, no active query → metadata count. await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); // No query → defaults to the Metadata data source (Y-metric controls show). await expect( page.getByTestId("chart-options").getByText("Y metric"), ).toBeVisible(); }); test("data-source toggle swaps metadata vs search controls", async ({ page, }) => { await page.goto("/"); await runSearch(page, "alpha"); await openChart(page); const opts = page.getByTestId("chart-options"); // Active query → defaults to Search matches: the Plot metric select shows. await expect(opts.getByText("Plot")).toBeVisible(); await expect(opts.getByText("Y metric")).toHaveCount(0); await opts.getByRole("button", { name: "Metadata" }).click(); await expect(opts.getByText("Y metric")).toBeVisible(); await expect(opts.getByText("Plot")).toHaveCount(0); }); test("metadata charts can break down by Type (video/livestream)", async ({ page, }) => { await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); const opts = page.getByTestId("chart-options"); await opts.locator('label:has-text("Dimension") select').selectOption("category"); await opts.locator('label:has-text("Field") select').selectOption("type"); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); // All three fixtures are videos (not livestreams) → one "Video" bucket. await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); }); test("metadata charts can break down by Media type and Status", async ({ page, }) => { await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); const opts = page.getByTestId("chart-options"); const dimension = opts.locator('label:has-text("Dimension") select'); const field = opts.locator('label:has-text("Field") select'); // Media type → video / livestream / short (3 fixtures, one of each). await dimension.selectOption("category"); await field.selectOption("mediaType"); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); // Status → available (×2) + deleted (×1). await field.selectOption("status"); await expect(page.locator(".recharts-surface")).toBeVisible(); await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); // Tag → multi-valued (one fixture has two tags). await field.selectOption("tag"); await expect(page.locator(".recharts-surface")).toBeVisible(); }); test("time charts can bin on the Downloaded acquisition date", async ({ page, }) => { await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); const opts = page.getByTestId("chart-options"); const xAxis = page.locator(".recharts-xAxis"); // Default metadata chart bins on Upload date → the three fixtures span three // months (2026-01..03). await expect(xAxis).toContainText("2026-01"); await expect(xAxis).toContainText("2026-03"); // Switch the time axis to the Downloaded acquisition date. All three // fixtures were downloaded in 2026-05, so the chart collapses to one bucket. await opts .locator('label:has-text("Date field") select') .selectOption("downloadedDate"); await expect(xAxis).toContainText("2026-05"); await expect(xAxis).not.toContainText("2026-01"); // Still three videos, just re-binned by when we added them. await expect(page.getByTestId("chart-caption")).toContainText("3 videos"); }); test("Examples menu sets the chart shape", async ({ page }) => { await page.goto("/"); await runSearch(page, "alpha"); await openChart(page); const chartType = page .getByTestId("chart-options") .locator("select") .first(); await expect(chartType).toHaveValue("line"); // search default await page.getByTestId("chart-examples-button").click(); await page .getByTestId("chart-examples-menu") .getByRole("button", { name: "Term matches by channel" }) .click(); // The "matches by channel" preset is a bar chart. await expect(chartType).toHaveValue("bar"); }); test("share + reload round-trips the chart view and shape", async ({ page, }) => { await page.goto("/"); await runSearch(page, "alpha"); await openChart(page); const chartType = page .getByTestId("chart-options") .locator("select") .first(); await chartType.selectOption("bar"); await page .getByTestId("profiles-row") .getByRole("button", { name: "Share current search" }) .click(); const clip = await page.evaluate(() => navigator.clipboard.readText()); const url = new URL(clip); expect(url.searchParams.get("qt")).toBeTruthy(); expect(url.searchParams.get("view")).toBe("chart"); expect(url.searchParams.get("cs")).toBeTruthy(); // A fresh load of the live URL restores chart view + the bar shape. await page.reload(); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); await expect( page.getByTestId("chart-options").locator("select").first(), ).toHaveValue("bar"); }); // THE SURFACE GAP (common/components/charts/surfaceGap.ts): the segments of // a stacked bar are parted by a 2 px stroke in the colour behind the plot — // the chart card — never by a line of their own. Stacked AREAS keep their // series-coloured top edge (a surface stroke there erased small values and // cut peaks). test("stacked bars are parted by a gap in the card's colour; stacked areas keep their own edge", async ({ page, }) => { await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); const opts = page.getByTestId("chart-options"); await opts.locator('label:has-text("Chart type") select').selectOption("stackedBar"); await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType"); const bars = page.locator(".recharts-bar-rectangle path"); await expect(bars.first()).toBeVisible({ timeout: 20_000 }); const surface = await cardColour(page); const text = await page.evaluate(() => getComputedStyle(document.body).color); expect(surface).not.toBe(text); const strokes = (sel: string) => page.locator(sel).evaluateAll((els) => els.map((e) => [getComputedStyle(e).stroke, getComputedStyle(e).strokeWidth]), ); const barStrokes = await strokes(".recharts-bar-rectangle path"); expect(barStrokes.length).toBeGreaterThan(0); for (const s of barStrokes) expect(s).toEqual([surface, "2px"]); await opts.locator('label:has-text("Chart type") select').selectOption("area"); await expect(page.locator(".recharts-area-curve").first()).toBeAttached({ timeout: 20_000 }); const edges = await page.locator(".recharts-area").evaluateAll((els) => els.map((g) => [ getComputedStyle(g.querySelector(".recharts-area-curve")!).stroke, getComputedStyle(g.querySelector(".recharts-area-area")!).fill, ]), ); expect(edges.length).toBeGreaterThan(1); for (const [stroke, fill] of edges) { expect(stroke).toBe(fill); expect(stroke).not.toBe(surface); } }); // THE DATA IS WHAT IS PAINTED. Read back from a screenshot: at each month // the stack's topmost painted row is within 1 px of the value scale's y for // the month's true total (three fixture videos, one per month, so a total of // 1 each), and every series with data shows pixels of its own fill. for (const width of [390, 1280]) { test(`${width} px: a stacked area paints its true total and every band`, async ({ page }) => { await page.setViewportSize({ width, height: 1000 }); await page.goto("/"); await showAll(page); await expect(page.getByTestId("results-summary")).toContainText("All videos"); await openChart(page); const opts = page.getByTestId("chart-options"); // Collapsed on a phone. if (!(await opts.evaluate((el) => (el as HTMLDetailsElement).open))) { await opts.locator("summary").click(); } await opts.locator('label:has-text("Chart type") select').selectOption("area"); await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType"); const svg = page.locator(".recharts-surface").first(); await expect(page.locator(".recharts-area")).toHaveCount(3, { timeout: 20_000 }); // No tooltip or active dot over the plot, and recharts' entry animation done. await page.mouse.move(0, 0); await expect(page.locator(".recharts-tooltip-wrapper")).toBeHidden(); await page.waitForTimeout(1_600); const geo = await svg.evaluate((el) => { const num = (t: Element) => Number((t.textContent ?? "").replace(/[^\d.-]/g, "")); // The value scale: each tick's value, at its gridline's y (a tick // label sits a pixel off its line). const values = [...el.querySelectorAll(".recharts-yAxis .recharts-cartesian-axis-tick-value")] .map(num) .sort((p, q) => p - q); const lines = [...el.querySelectorAll(".recharts-cartesian-grid-horizontal line")] .map((l) => Number(l.getAttribute("y1"))) .sort((p, q) => q - p); const yTicks = values.map((v, i) => ({ v, y: lines[i] })); const xTicks = [...el.querySelectorAll(".recharts-xAxis .recharts-cartesian-axis-tick-value")].map((t) => Number(t.getAttribute("x")), ); const fills = [...el.querySelectorAll(".recharts-area-area")].map((p) => { const cs = getComputedStyle(p); return { fill: cs.fill, opacity: Number(cs.fillOpacity) }; }); return { yTicks, xTicks, fills }; }); const [a, b] = [geo.yTicks[0], geo.yTicks[geo.yTicks.length - 1]]; const yOf = (v: number) => a.y + ((v - a.v) * (b.y - a.y)) / (b.v - a.v); const ground = rgbOf(await cardColour(page)); const shot = await painted(page, svg, { columns: geo.xTicks, ground, colours: geo.fills.map((f) => over(rgbOf(f.fill), f.opacity, ground)), tol: 10, }); expect(geo.xTicks.length).toBe(3); for (const [i, top] of shot.tops.entries()) { expect(top, `month ${i}: nothing painted`).not.toBeNull(); expect(Math.abs(top! - yOf(1)), `month ${i}: top ${top} vs ${yOf(1).toFixed(1)}`).toBeLessThanOrEqual(1); } for (const [i, c] of shot.counts.entries()) { expect(c.columns, `series ${i}'s own colour`).toBeGreaterThan(0); } }); } });