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
388 lines
15 KiB
TypeScript
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"]);
|
|
});
|