278 lines
10 KiB
TypeScript
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",
|
|
]);
|
|
});
|