admin: draw the overview charts with visx
the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0 primitives; rendering, colours and themes stay the app's own. all four charts share one hover treatment: the client chart gains the tooltip and dimming the query timeline had, the donuts gain both, an open tooltip follows a data refresh instead of going stale, and it retires when the window rolls. the timeline's third series is named allowed instead of other, and the client chart's other aggregate disappears from a window where it counted nothing. licenses gain the isc text for the bundled d3 modules.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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 type { StatsClients } from "@/lib/types";
|
||||
import ClientChart 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[]): StatsClients {
|
||||
return {
|
||||
period: "24h",
|
||||
since: SINCE,
|
||||
until: SINCE + other.length * BUCKET,
|
||||
bucket_seconds: BUCKET,
|
||||
coverage: { complete: true, available_since: SINCE },
|
||||
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: StatsClients) {
|
||||
const client = createQueryClient();
|
||||
const tree = (next: StatsClients) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ClientChart data={next} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = renderBare(tree(data));
|
||||
return { ...result, rerender: (next: StatsClients) => 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",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user