Files
nxdns/admin/src/features/overview/ClientChart.test.tsx
T

278 lines
10 KiB
TypeScript

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) => (
<QueryClientProvider client={client}>
<ClientChart data={next} />
</QueryClientProvider>
);
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<SVGRectElement>('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",
]);
});