import { fireEvent, render, screen, within } from "@testing-library/react"; import * as stylex from "@stylexjs/stylex"; import { formatTime } from "@/lib/format"; import type { Bucket, StatsTimeseries } from "@/lib/types"; import { styles as shared } from "@/ui/styles"; import TimeseriesChart from "./TimeseriesChart"; const SINCE = 1_700_000_000; function timeseries(buckets: Bucket[]): StatsTimeseries { return { period: "24h", since: SINCE, until: SINCE + buckets.length * 1800, bucket_seconds: 1800, coverage: { complete: true, available_since: SINCE }, buckets, }; } function counting(bucketCount: number): StatsTimeseries { return timeseries( Array.from({ length: bucketCount }, (_, i) => ({ ts: SINCE + i * 1800, queries: i + 1, blocked: 1, cached: 1, })), ); } /** The element wearing the shared hidden style, found by its compiled classes. */ function hiddenElement(container: HTMLElement): Element | null { const classes = stylex.props(shared.srOnly).className?.split(" ").filter(Boolean) ?? []; expect(classes.length).toBeGreaterThan(0); return container.querySelector(classes.map((name) => `.${name}`).join("")); } /** The transparent per-bucket hit targets, in bucket order. */ function overlayRects(container: HTMLElement): SVGRectElement[] { return Array.from(container.querySelectorAll('rect[fill="transparent"]')); } test("the data table is the SVG's accessible equivalent", () => { render(); expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy(); const table = screen.getByRole("table", { name: "Queries per time bucket" }); expect(within(table).getAllByRole("row").length).toBe(4); }); /** * `overflow` does not apply to a table box and `height` on one is a minimum, so * the hidden style has to sit on a block container wrapping the table. Worn by * the table itself it clips the paint but not the layout, and 48 invisible rows * push the document's scroll height a screen past the app shell. */ test("the hidden data table is clipped by a block wrapper, not by the table itself", () => { const { container } = render(); const hidden = hiddenElement(container); expect(hidden?.tagName).toBe("DIV"); expect(hidden?.querySelector("table")).not.toBeNull(); }); /** * "Allowed" is what the reported total leaves over, and the three counts come * from separate columns that a partial write can leave inconsistent. A negative * remainder would draw a segment upside down. */ test("allowed is the remainder of the reported total, clamped at zero", () => { const { container } = render( , ); const row = within(screen.getByRole("table")).getAllByRole("row")[1]; expect( within(row) .getAllByRole("cell") .map((cell) => cell.textContent), ).toEqual(["10", "8", "5", "0"]); // Blocked and cached are drawn; the empty "allowed" segment is not. expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0); // The scale comes from the reported total, not from the stack's own sum. // Scaling to the sum would reach 13 here and leave the bar four fifths of the // way up a plot whose own numbers say it is full. const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent); expect(ticks[ticks.length - 1]).toBe("10"); }); test("a window with no queries says so instead of drawing an empty grid", () => { const { container } = render( , ); expect(screen.getByText("No queries in this period.")).toBeTruthy(); expect(container.querySelector("svg")).toBeNull(); }); test("no bucket at all says the same thing", () => { render(); expect(screen.getByText("No queries in this period.")).toBeTruthy(); }); /** * The three category colours are fixed constants of this chart rather than * anything derived from `seriesColors`, which would paint "other" grey. */ test("the segments are drawn in this chart's own category colours", () => { const { container } = render( , ); const fills = Array.from(container.querySelectorAll("rect")) .map((rect) => rect.getAttribute("fill")) .filter((fill) => fill !== "transparent"); expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]); }); /** * The graphic carries no ``: it duplicated the tooltip, the browser * showed it after its own delay and out of the app's styling, and the hidden * table is already the accessible equivalent. */ test("hover text is the tooltip alone, never a bare SVG title", () => { const { container } = render(<TimeseriesChart data={counting(3)} />); expect(container.querySelectorAll("title")).toHaveLength(0); }); /** The hit target is the whole column slot, including the space above a short stack. */ test("each bucket's hit target spans the full plot height", () => { const { container } = render(<TimeseriesChart data={counting(3)} />); const rects = overlayRects(container); expect(rects).toHaveLength(3); for (const rect of rects) { expect(rect.getAttribute("y")).toBe("8"); expect(rect.getAttribute("height")).toBe("210"); } }); /** * A band scale spends a gap after the last column as well as between them, so a * full-step hit target on the last bucket would reach into the right margin and * catch pointers that are past the plot entirely. */ test("the last hit target stops at the plot's right edge", () => { const { container } = render(<TimeseriesChart data={counting(3)} />); const last = overlayRects(container).at(-1) as SVGRectElement; const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width")); // 640 fallback width, less the 44px left and 8px right margins. expect(right).toBeCloseTo(44 + 588, 6); }); test("pointing at a bucket names its total and every series, and dims the rest", () => { const { container } = render( <TimeseriesChart data={timeseries([ { ts: SINCE, queries: 100, blocked: 40, cached: 10 }, { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, ])} />, ); fireEvent.mouseOver(overlayRects(container)[0]); const tooltip = container.querySelector("dl") as HTMLElement; expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE)); const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent); expect(values).toEqual(["100", "40", "10", "50"]); const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent); expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]); // One swatch per series, in the colour the segment is drawn in. const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style")); expect(swatches[0]).toContain("#ef4444"); expect(swatches[1]).toContain("#059669"); expect(swatches[2]).toContain("#3b82f6"); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]); }); /** * The tooltip sits 8px down from the chart's top edge and 8px to the side of the * slot it names — the placement the hand-rolled tooltip had, restored over * visx's own 10px defaults. */ test("the tooltip is offset 8px from the chart top and from the bucket it names", () => { const { container } = render(<TimeseriesChart data={counting(2)} />); fireEvent.mouseOver(overlayRects(container)[0]); // The first slot spans 44 to 44 + step, so its centre is 191.5 and the // tooltip sits 8px right of it. visx rounds the placement to whole pixels. const tooltip = container.querySelector(".visx-tooltip") as HTMLElement; expect(tooltip.style.transform).toBe("translate(200px, 8px)"); }); /** * `withBoundingRects` measures its node once, on mount. Sharing one mount across * buckets would place a wide bucket's tooltip with a narrow bucket's measured * width, which flips or clips it at the right-hand edge of the plot. Remounting * per bucket is what forces a fresh measurement; jsdom reports every rect as * zero, so the mount is what this can observe, not the measurement itself. */ test("each bucket gets its own tooltip mount, so each is measured for itself", () => { const { container } = render(<TimeseriesChart data={counting(2)} />); const rects = overlayRects(container); fireEvent.mouseOver(rects[0]); const first = container.querySelector(".visx-tooltip"); fireEvent.mouseOver(rects[1]); const second = container.querySelector(".visx-tooltip"); expect(first).not.toBeNull(); expect(second).not.toBe(first); }); /** * The window refreshes every half minute under an open tooltip. The tooltip * holds a bucket index and reads the numbers out of the render it is drawing, so * a refresh that keeps the same buckets updates it rather than leaving last * minute's counts on screen. */ test("a refresh in the same window retells the hovered bucket with the new counts", () => { const before = timeseries([ { ts: SINCE, queries: 100, blocked: 40, cached: 10 }, { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, ]); const { container, rerender } = render(<TimeseriesChart data={before} />); fireEvent.mouseOver(overlayRects(container)[0]); expect( Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent), ).toEqual(["100", "40", "10", "50"]); rerender( <TimeseriesChart data={timeseries([ { ts: SINCE, queries: 120, blocked: 60, cached: 10 }, { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, ])} />, ); const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map( (dd) => dd.textContent, ); expect(values).toEqual(["120", "60", "10", "50"]); }); /** * A rolling window is the case a stored copy gets wrong: the bucket the pointer * was over is gone, so index 0 now names a different span. The tooltip goes away * rather than describing a bucket that is no longer drawn, and nothing stays * dimmed behind it. Rolling back to the earlier window must not bring it back * either: the selection is deleted when the window moves, not held aside. */ test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => { const { container, rerender } = render( <TimeseriesChart data={timeseries([ { ts: SINCE, queries: 100, blocked: 40, cached: 10 }, { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, ])} />, ); fireEvent.mouseOver(overlayRects(container)[0]); expect(container.querySelectorAll("dl")).toHaveLength(1); rerender( <TimeseriesChart data={{ ...timeseries([ { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, { ts: SINCE + 3600, queries: 70, blocked: 7, cached: 7 }, ]), since: SINCE + 1800, }} />, ); expect(container.querySelectorAll("dl")).toHaveLength(0); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true); rerender( <TimeseriesChart data={timeseries([ { ts: SINCE, queries: 100, blocked: 40, cached: 10 }, { ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 }, ])} />, ); expect(container.querySelectorAll("dl")).toHaveLength(0); }); /** * The same bucket can change width between refreshes — a count crossing a digit * boundary, a client name arriving — and a mount measured at the old width is * placed at the wrong one. A content change therefore remounts the tooltip, the * same way moving between buckets does. */ test("a bucket whose numbers change is remounted, so it is measured again", () => { const { container, rerender } = render( <TimeseriesChart data={timeseries([{ ts: SINCE, queries: 9, blocked: 4, cached: 1 }])} />, ); fireEvent.mouseOver(overlayRects(container)[0]); const first = container.querySelector(".visx-tooltip"); rerender(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 1000, blocked: 4, cached: 1 }])} />); const second = container.querySelector(".visx-tooltip"); expect(first).not.toBeNull(); expect(second).not.toBeNull(); expect(second).not.toBe(first); }); test("leaving the chart takes the tooltip and the dimming with it", () => { const { container } = render(<TimeseriesChart data={counting(3)} />); fireEvent.mouseOver(overlayRects(container)[0]); expect(container.querySelectorAll("dl")).toHaveLength(1); fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement); expect(container.querySelectorAll("dl")).toHaveLength(0); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true); }); /** * The third series is every query neither blocked nor served from cache. It was * called "Other", which named the arithmetic rather than the thing. */ test("the remainder series is called Allowed everywhere it surfaces", () => { const { container } = render( <TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />, ); const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent); expect(legend).toEqual(["Blocked", "Cached", "Allowed"]); expect( within(screen.getByRole("table")) .getAllByRole("columnheader") .map((cell) => cell.textContent), ).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]); fireEvent.mouseOver(overlayRects(container)[0]); const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt")); expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]); expect(screen.queryByText("Other")).toBeNull(); }); /** * A window where everything was blocked or served from cache has no allowed * queries, and that is worth reading rather than hiding: an absent series would * say the same thing as a series nobody looked at. The three categories are all * real answers a query can get, so none of them is dropped for counting zero. * The client chart's "Other" is dropped at zero, but that one aggregates clients * beyond the top eight rather than naming a kind of answer. */ test("a window with nothing allowed keeps the series at zero", () => { const { container } = render( <TimeseriesChart data={timeseries([ { ts: SINCE, queries: 4, blocked: 4, cached: 0 }, { ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 }, ])} />, ); const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent); expect(legend).toEqual(["Blocked", "Cached", "Allowed"]); fireEvent.mouseOver(overlayRects(container)[0]); const tooltip = container.querySelector("dl") as HTMLElement; expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([ "Queries", "Blocked", "Cached", "Allowed", ]); expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]); });