src/components/dives/gas-use-chart.browser.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { GasUseChart } from "./gas-use-chart";
import type { DiveGasUsePoint } from "@/lib/api/dive-stats";
import type { ChartScope } from "@/lib/chart-period";
import { diveWallClockTime } from "@/lib/date-time";
import {
overlappingLabels,
sidewaysScrollers,
withinSides,
} from "@/test/chart-layout";
import { centreOf, fingerDown, fingerMove, tap } from "@/test/touch";
// Load-bearing, as in `dive-profile-chart.browser.test.tsx`, whose first test
// fails without it: without the app's Tailwind a `min-w-*` class computes to
// nothing, so a chart wider than its container would pass everything below.
import "@/app/globals.css";
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ user: { uuid: "user-1", units: "metric" } }),
}));
function point(startTime: string, index: number): DiveGasUsePoint {
return {
dive_uuid: `d${index}`,
dive_number: index + 1,
start_time: startTime,
avg_depth: 18.4,
gas_use: { gas_used: 1520.7, rmv: 12 + (index % 7), sac_bar_per_min: 0.93 },
} as DiveGasUsePoint;
}
// A June dive every year from 2002, then one a month through 2025 and a run of
// them in August: the most year labels the "all" axis has to thin, all twelve
// months, and a month with dots at both ends.
const POINTS = [
...Array.from({ length: 23 }, (_, index) => `${2002 + index}-06-15`),
...Array.from(
{ length: 12 },
(_, month) => `2025-${String(month + 1).padStart(2, "0")}-10`,
),
"2025-08-01",
"2025-08-31",
"2026-03-02",
]
.sort()
.map((day, index) => point(`${day}T10:00:00+02:00`, index));
const ANCHOR = diveWallClockTime("2025-08-10T10:00:00+02:00");
// The chart's width on the Home page at a 375 px and a 320 px viewport, and at
// 1024 px.
const WIDTHS = { phone: 293, smallPhone: 238, desktop: 910 };
const SCOPES: ChartScope[] = ["all", "year", "month"];
const CASES = Object.entries(WIDTHS).flatMap(([name, width]) =>
SCOPES.map((scope) => [name, scope, width] as const),
);
function renderAt(width: number, scope: ChartScope) {
const { container } = render(
<div style={{ width }}>
<GasUseChart points={POINTS} scope={scope} anchor={ANCHOR} />
</div>,
);
const frame = container.firstElementChild as HTMLElement;
const svg = frame.querySelector("svg") as SVGSVGElement;
return { frame, svg };
}
describe("the gas consumption chart's layout", () => {
it.each(CASES)(
"draws no label over another at %s width, scope %s",
(_, scope, width) => {
const { svg } = renderAt(width, scope);
expect(overlappingLabels(svg)).toEqual([]);
},
);
it.each(CASES)(
"fits %s width without scrolling sideways, scope %s",
(_, scope, width) => {
const { frame, svg } = renderAt(width, scope);
expect(sidewaysScrollers(svg, frame)).toEqual([]);
},
);
it.each(SCOPES)(
"keeps the hover card inside a phone-width chart, scope %s",
(scope) => {
const { frame, svg } = renderAt(WIDTHS.smallPhone, scope);
const dots = [...svg.querySelectorAll("a")];
for (const dot of [dots[0], dots[dots.length - 1]]) {
fireEvent.mouseEnter(dot);
const card = frame.querySelector('[role="presentation"]') as Element;
expect(withinSides(card, frame)).toBe(true);
fireEvent.mouseLeave(dot);
}
},
);
});
describe("the gas consumption chart under a finger", () => {
function renderYear() {
const { frame, svg } = renderAt(WIDTHS.phone, "year");
const dots = [...svg.querySelectorAll("[data-point]")];
const card = () => frame.querySelector('[role="presentation"]');
return { svg, dots, card };
}
// Whether a click on `target` would open its link. Read after the chart's own
// handler has had its say, and then cancelled, so the test page stays put.
function clickOpens(target: Element): boolean {
let opens = false;
const watch = (event: Event) => {
opens = !event.defaultPrevented;
event.preventDefault();
};
window.addEventListener("click", watch);
fireEvent.click(target);
window.removeEventListener("click", watch);
return opens;
}
const diveOf = (dot: Element) =>
`Dive #${Number(dot.getAttribute("data-point")) + 1}`;
// Where a dot is drawn, in client coordinates - from its viewBox position
// rather than its box, since a freshly drawn dot is still easing into place.
const pointOf = (svg: SVGSVGElement, dot: Element) => {
const box = svg.getBoundingClientRect();
const scale = box.width / svg.viewBox.baseVal.width;
return {
x: box.left + Number(dot.getAttribute("data-x")) * scale,
y: box.top + Number(dot.getAttribute("data-y")) * scale,
};
};
it("reads a dot on the first tap rather than opening its dive", () => {
const { dots, card } = renderYear();
const dot = dots[4];
tap(dot, centreOf(dot));
expect(clickOpens(dot)).toBe(false);
expect(card()).toHaveTextContent(diveOf(dot));
});
it("puts a link to the dive it read on the card", () => {
const { dots, card } = renderYear();
const dot = dots[4];
tap(dot, centreOf(dot));
clickOpens(dot);
expect(card()?.querySelector("a")).toHaveAttribute(
"href",
expect.stringContaining(`/dives/d${dot.getAttribute("data-point")}`),
);
});
it("opens the dive on a second tap of the same dot", () => {
const { dots } = renderYear();
const dot = dots[4];
tap(dot, centreOf(dot));
clickOpens(dot);
tap(dot, centreOf(dot));
expect(clickOpens(dot)).toBe(true);
});
it("reads the dot the tap's click lands on, which a second tap opens", () => {
// The browser aims a tap's click at the nearest link under the fingertip,
// which need not be the dot whose hit circle the finger went down on.
const { dots, card } = renderYear();
tap(dots[4], centreOf(dots[4]));
expect(clickOpens(dots[5])).toBe(false);
expect(card()).toHaveTextContent(diveOf(dots[5]));
tap(dots[5], centreOf(dots[5]));
expect(clickOpens(dots[5])).toBe(true);
});
it("opens the dive on the first mouse click", () => {
const { dots } = renderYear();
fireEvent.pointerDown(dots[4], { pointerType: "mouse" });
expect(clickOpens(dots[4])).toBe(true);
});
it("scrubs through the dots by time as the finger drags sideways", () => {
const { svg, dots, card } = renderYear();
const first = pointOf(svg, dots[0]);
const later = pointOf(svg, dots[7]);
fingerDown(svg, first);
fingerMove(svg, { x: later.x, y: first.y });
expect(card()).toHaveTextContent(diveOf(dots[7]));
});
});