import { fireEvent, render as renderBare, screen, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { createQueryClient } from "@/lib/queryClient"; import { formatTime } from "@/lib/format"; import ClientChart, { type ClientChartData } from "./ClientChart"; import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors"; const SINCE = 1_700_000_000; const BUCKET = 1800; function clients(named: { client: string; buckets: number[] }[], other: number[]): ClientChartData { return { since: SINCE, bucket_seconds: BUCKET, clients: named, other }; } const TWO_BUCKETS = clients( [ { client: "192.0.2.30", buckets: [10, 1] }, { client: "192.0.2.31", buckets: [20, 1] }, ], [5, 1], ); /** * The chart looks a client's registered name up, so it needs a query client. No * client is registered in these fixtures, which is what leaves the addresses on * screen as the labels. */ function render(data: ClientChartData) { const client = createQueryClient(); const tree = (next: ClientChartData) => ( ); const result = renderBare(tree(data)); return { ...result, rerender: (next: ClientChartData) => result.rerender(tree(next)) }; } beforeEach(() => { vi.stubGlobal( "fetch", vi.fn(async () => Promise.resolve( new Response(JSON.stringify({ clients: [] }), { status: 200, headers: { "content-type": "application/json" }, }), ), ), ); }); afterEach(() => vi.unstubAllGlobals()); function overlayRects(container: HTMLElement): SVGRectElement[] { return Array.from(container.querySelectorAll('rect[fill="transparent"]')); } test("the data table is the SVG's accessible equivalent", () => { render(TWO_BUCKETS); expect(screen.getByRole("img", { name: /client activity over time/i })).toBeTruthy(); const table = screen.getByRole("table", { name: "Queries per client per time bucket" }); expect(within(table).getAllByRole("row").length).toBe(3); }); /** * The x-axis is this response's own `since` plus one bucket width per column. * The timeseries endpoint aligns its buckets with these, which is what lets the * two charts stack above one another without either knowing about the other. */ test("the columns are timestamped from this response's own window", () => { render(TWO_BUCKETS); const rows = within(screen.getByRole("table")).getAllByRole("rowheader"); expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]); }); /** * Every series here is a disjoint part of the whole, so the scale has to come * from the tallest column's own sum. Taking it from the largest single value * instead would run the tallest column off the top of the plot: the axis has to * reach 35 here, not 20. */ test("the value scale covers the tallest column's total, not its largest series", () => { const { container } = render(clients([{ client: "192.0.2.30", buckets: [20] }], [15])); const labels = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) => label.textContent); expect(labels[labels.length - 1]).toBe("35"); }); test("the series are drawn in the colour of the client's address, and Other in its own", () => { const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5])); const fills = Array.from(container.querySelectorAll("rect")) .map((rect) => rect.getAttribute("fill")) .filter((fill) => fill !== "transparent"); expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]); }); test("a window with no queries says so instead of drawing an empty grid", () => { const { container } = render(clients([{ client: "192.0.2.30", buckets: [0, 0] }], [0, 0])); expect(screen.getByText("No queries in this period.")).toBeTruthy(); expect(container.querySelector("svg")).toBeNull(); }); test("no bucket at all says the same thing", () => { render(clients([], [])); expect(screen.getByText("No queries in this period.")).toBeTruthy(); }); test("hover text is the tooltip alone, never a bare SVG title", () => { const { container } = render(TWO_BUCKETS); expect(container.querySelectorAll("title")).toHaveLength(0); }); test("each bucket's hit target spans the full plot height", () => { const { container } = render(TWO_BUCKETS); const rects = overlayRects(container); expect(rects).toHaveLength(2); 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. */ test("the last hit target stops at the plot's right edge", () => { const { container } = render(TWO_BUCKETS); 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); }); /** * 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 { container, rerender } = render(TWO_BUCKETS); fireEvent.mouseOver(overlayRects(container)[0]); expect( Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent), ).toEqual(["35", "10", "20", "5"]); rerender( clients( [ { client: "192.0.2.30", buckets: [11, 1] }, { client: "192.0.2.31", buckets: [22, 1] }, ], [6, 1], ), ); expect( Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent), ).toEqual(["39", "11", "22", "6"]); }); /** * 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. */ test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => { const { container, rerender } = render(TWO_BUCKETS); fireEvent.mouseOver(overlayRects(container)[0]); expect(container.querySelectorAll("dl")).toHaveLength(1); rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET }); 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 placement the hand-rolled tooltip had, restored over visx's 10px defaults. */ test("the tooltip is offset 8px from the chart top and from the bucket it names", () => { const { container } = render(TWO_BUCKETS); fireEvent.mouseOver(overlayRects(container)[0]); const tooltip = container.querySelector(".visx-tooltip") as HTMLElement; expect(tooltip.style.transform).toBe("translate(200px, 8px)"); }); /** * `withBoundingRects` measures its node once, on mount, so the buckets must not * share one. 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(TWO_BUCKETS); const rects = overlayRects(container); fireEvent.mouseOver(rects[0]); const first = container.querySelector(".visx-tooltip"); fireEvent.mouseOver(rects[1]); expect(first).not.toBeNull(); expect(container.querySelector(".visx-tooltip")).not.toBe(first); }); test("pointing at a bucket names its total and every series, and dims the rest", () => { const { container } = render(TWO_BUCKETS); fireEvent.mouseOver(overlayRects(container)[0]); const tooltip = container.querySelector("dl") as HTMLElement; expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE)); expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([ "Queries", "192.0.2.30", "192.0.2.31", "Other", ]); expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]); const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style")); expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30"))); expect(swatches[2]).toContain(seriesColor(OTHER_KEY)); const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]")); expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]); }); test("leaving the chart takes the tooltip and the dimming with it", () => { const { container } = render(TWO_BUCKETS); 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); }); /** * "Other" is everything outside the top eight clients. In a window where it * counted nothing there is no eighth client to aggregate, and the entry would * appear in the legend, the stack, the tooltip and the table saying only that it * is empty. The named clients stay at zero: a client that went quiet is a fact. */ test("a window where Other counted nothing drops it from every surface", () => { const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0])); expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual(["192.0.2.30"]); expect( within(screen.getByRole("table")) .getAllByRole("columnheader") .map((cell) => cell.textContent), ).toEqual(["Time", "192.0.2.30"]); fireEvent.mouseOver(overlayRects(container)[0]); const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt")); expect(terms.map((term) => term.textContent)).toEqual(["Queries", "192.0.2.30"]); }); test("one query outside the named clients is enough to keep Other", () => { const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1])); expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([ "192.0.2.30", "Other", ]); });