import { test, expect, type Page } from "@playwright/test"; import { buildFixtureSummary } from "./fixture-summary"; import { painted, rgbOf } from "../../common/testing/chartPixels"; import { siteChartColors } from "../../common/lib/siteColor"; import { OTHER_COLOR, growthLayers } from "../app/lib/growthGaps"; // The growth chart's bands are parted by the marks spec's SURFACE GAP: 2 px // along each band's upper edge, in the colour behind the plot — the page // ground, which the chart sits on — never a line in the text colour // (ArchiveGrowthChart.tsx, globals.css `.growth-gap`), and the reader's Canvas // in forced colours. The fixture summary (fixture-summary.ts) has six sites // with monthly data, so the chart and its gaps render; its last two are each // under 5 % of the chart's total, so they are drawn as one Other band on top // (lib/growthGaps.ts, release 14 slice CF). const BASE_KEY = "ytdlp-tb:base"; const resolveColor = (page: Page, css: string) => page.evaluate((c) => { const el = document.createElement("span"); el.style.color = c; document.body.append(el); const out = getComputedStyle(el).color; el.remove(); return out; }, css); const gaps = (page: Page) => page.locator("figure svg path.growth-gap").evaluateAll((els) => els.map((el) => { const s = getComputedStyle(el); return { stroke: s.stroke, width: s.strokeWidth, scaling: s.vectorEffect }; }), ); test("the bands are parted by a 2 px gap in the ground's colour, never the text colour", async ({ page, }) => { await page.goto("/"); for (const base of ["light", "dark"] as const) { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); // The chart sits on the page ground: nothing between them paints. const holder = await page.locator("figure").evaluate((el) => { for (let n: Element | null = el; n && n !== document.body; n = n.parentElement) { const bg = getComputedStyle(n).backgroundColor; if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg; } return getComputedStyle(document.body).backgroundColor; }); const ground = await resolveColor(page, "var(--background)"); const text = await resolveColor(page, "var(--foreground)"); expect(holder, `${base}: what the chart sits on`).toBe(ground); expect(ground).not.toBe(text); const seen = await gaps(page); expect(seen.length, base).toBeGreaterThan(0); for (const g of seen) { expect(g, base).toEqual({ stroke: ground, width: "2px", scaling: "non-scaling-stroke" }); } } }); test("in forced colours the gap is the reader's Canvas", async ({ page }) => { await page.emulateMedia({ forcedColors: "active" }); await page.goto("/"); const canvas = await resolveColor(page, "Canvas"); const seen = await gaps(page); expect(seen.length).toBeGreaterThan(0); for (const g of seen) expect(g.stroke).toBe(canvas); }); // One set of gaps per plot height (lib/growthGaps.ts), each shown only at its // own breakpoint: the set worked out for the height the plot is drawn at. for (const [width, shown] of [ [390, "200"], [768, "260"], [1280, "300"], ] as const) { test(`${width} px: only the ${shown} px plot's gaps are shown`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.goto("/"); const plot = page.locator('figure [role="img"]'); expect(Math.round((await plot.boundingBox())!.height)).toBe(Number(shown)); const sets = await page.locator("figure svg g[data-plot-height]").evaluateAll((els) => els.map((g) => [g.getAttribute("data-plot-height"), getComputedStyle(g).display !== "none"]), ); expect(sets.filter(([, on]) => on).map(([h]) => h)).toEqual([shown]); }); } // THE DATA IS WHAT IS PAINTED. Read back from a screenshot of the plot: at the // busiest month the stack's topmost painted row is within 1 px of where the // month's true total sits on the value scale, and every band with data — each // kept site's and Other's — shows pixels of its own colour: the gaps take no // band away. for (const width of [390, 1280]) { test(`${width} px: the chart paints its peak at its true height and every band`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.goto("/"); const summary = buildFixtureSummary(); const months = summary.monthly ?? []; const sites = summary.sites; const layers = growthLayers(months, sites); const totals = months.map((m) => sites.reduce((a, x) => a + (m.bySite[x.siteId] ?? 0), 0)); const peak = Math.max(...totals); const peakAt = totals.indexOf(peak); const plot = page.locator('figure [role="img"]'); const box = (await plot.boundingBox())!; // The value scale, from the labels on its gridlines (a label's `top` is // its gridline's place, 1 − t / yMax of the plot's height). const scale = await plot.evaluate((el) => { const s = el.querySelector("span.tabular") as HTMLElement; return { t: Number(s.textContent!.replace(/[^\d.]/g, "")), top: parseFloat(s.style.top) / 100 }; }); const yMax = scale.t / (1 - scale.top); const expected = (1 - peak / yMax) * box.height; const x = (peakAt / (months.length - 1)) * box.width; const ground = rgbOf(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)); // The legend's swatches, one per band, bottom-up (kept sites, then Other). const swatches = await page .locator("figure ul li span") .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor)); expect(swatches).toHaveLength(layers.length); const withData = layers.map((l) => months.some((m) => l.sites.some((si) => (m.bySite[sites[si].siteId] ?? 0) > 0)), ); const shot = await painted(page, plot, { columns: [x - 1, x, x + 1], ground, colours: swatches.map(rgbOf), tol: 12, run: 2, }); const top = Math.min(...shot.tops.filter((t): t is number => t !== null)); expect(Math.abs(top - expected), `top ${top} vs ${expected.toFixed(1)}`).toBeLessThanOrEqual(1); layers.forEach((l, k) => { if (withData[k]) expect(shot.counts[k].columns, `${l.key}'s colour`).toBeGreaterThan(0); }); }); } const resolveFill = (page: Page, css: string) => page.evaluate((c) => { const el = document.createElement("span"); el.style.backgroundColor = c; document.body.append(el); const out = getComputedStyle(el).backgroundColor; el.remove(); return out; }, css); // WCAG contrast of two "rgb(…)" colours. function contrast(a: string, b: string): number { const lum = (css: string) => { const [r, g, bl] = rgbOf(css).map((v) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * bl; }; const [hi, lo] = [lum(a), lum(b)].sort((p, q) => q - p); return (hi + 0.05) / (lo + 0.05); } // THE FOLD (lib/growthGaps.ts). The fixture's last two sites are each under // 5 % of the chart's total: ONE Other band, drawn last (on top), in the chart's // neutral grey. The legend shows the kept sites and Other; the table and // every month's title name every site. test("two sites under 5 % are one Other band on top: the legend shows the kept sites and Other, the table and the titles name all six", async ({ page, }) => { const summary = buildFixtureSummary(); const months = summary.monthly ?? []; const sites = summary.sites; const layers = growthLayers(months, sites); expect(layers.map((l) => l.key)).toEqual([ "fixture-one", "fixture-two", "fixture-three", "fixture-four", "(other)", ]); expect(layers.at(-1)!.sites).toEqual([4, 5]); const titles = sites.map((s) => s.siteTitle); await page.goto("/"); const figure = page.locator("figure").filter({ has: page.locator('[role="img"]') }); // The legend: the four kept sites, then Other. await expect(figure.locator("ul[aria-hidden='true'] li")).toHaveText([...titles.slice(0, 4), "Other"]); // The bands, in paint order: Other's is the last, in the neutral grey. const fills = await figure .locator("svg > path:not(.growth-gap)") .evaluateAll((els) => els.map((e) => e.getAttribute("fill"))); expect(fills).toEqual([...siteChartColors(sites).slice(0, 4), OTHER_COLOR]); // The image's label names the folded sites; the caption says what Other is. await expect(figure.locator('[role="img"]')).toHaveAttribute( "aria-label", /Fixture Five and Fixture Six, each under 5% of the total, are drawn together as Other\.$/, ); await expect(figure.locator("figcaption")).toContainText( "Instances under 5% of the total are drawn together as Other.", ); // Every month's title names every site with data that month, folded or not. const hits = await figure .locator("svg rect.growth-hit title") .evaluateAll((els) => els.map((e) => e.textContent ?? "")); expect(hits).toHaveLength(months.length); let foldedNamed = 0; months.forEach((m, i) => { for (const s of sites) { const v = m.bySite[s.siteId] ?? 0; if (v > 0) expect(hits[i], `${m.month}`).toContain(`\n${s.siteTitle} ${v.toLocaleString("en-US")}`); } if (hits[i].includes("\nFixture Five ") || hits[i].includes("\nFixture Six ")) foldedNamed++; }); expect(foldedNamed).toBeGreaterThan(0); expect(hits.join("\n")).not.toContain("\nOther"); // The table: a column per site, all six, then Total. await figure.locator("summary", { hasText: "Numbers by year" }).click(); await expect(figure.locator("table thead th")).toHaveText(["Year", ...titles, "Total"]); }); test("Other is its own grey (--chart-other), at least 3:1 on both grounds, and no kept site's colour", async ({ page, }) => { await page.goto("/"); for (const base of ["light", "dark"] as const) { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); const swatches = await page .locator("figure ul[aria-hidden='true'] li > span") .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor)); const other = swatches.at(-1)!; expect(other, base).toBe(await resolveFill(page, OTHER_COLOR)); // Its own token, set on each base (a Dark block without it would fall // back to the Light value from :root). expect(other, base).toBe(base === "light" ? "rgb(62, 84, 92)" : "rgb(98, 98, 92)"); expect(other, base).not.toBe(await resolveFill(page, "var(--chart-axis)")); const ground = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); expect(contrast(other, ground), `${base}: Other on the ground`).toBeGreaterThanOrEqual(3); expect(swatches.slice(0, -1), base).not.toContain(other); } });