Files
nxdns/admin/src/features/overview/TimeseriesChart.test.tsx
T
mokhtar 6a0630c288
Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
overview: one endpoint, live projections and a response cache (m36)
2026-08-27 17:48:20 +02:00

388 lines
15 KiB
TypeScript

import { fireEvent, render, screen, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import TimeseriesChart, { type TimeseriesData } from "./TimeseriesChart";
const SINCE = 1_700_000_000;
function timeseries(buckets: Bucket[]): TimeseriesData {
return { since: SINCE, bucket_seconds: 1800, buckets };
}
function counting(bucketCount: number): TimeseriesData {
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<SVGRectElement>('rect[fill="transparent"]'));
}
test("the data table is the SVG's accessible equivalent", () => {
render(<TimeseriesChart data={counting(3)} />);
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(<TimeseriesChart data={counting(48)} />);
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(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
);
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(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
{ ts: SINCE + 1800, queries: 0, blocked: 0, cached: 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(<TimeseriesChart data={timeseries([])} />);
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(
<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
);
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 `<title>`: 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"]);
});