src/components/dives/dive-activity-chart.browser.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { DiveActivityChart } from "./dive-activity-chart";
import type { DiveActivityPoint } from "@/lib/api/dive-stats";
import type { ChartScope } from "@/lib/chart-period";
import { activityBars, barCeiling } from "@/lib/dive-activity";
import {
overlappingLabels,
sidewaysScrollers,
withinSides,
} from "@/test/chart-layout";
import {
centreOf,
fingerDown,
fingerMove,
fingerUp,
pressElsewhere,
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";
// Twenty-five years of diving, and a 2025 with a dive in every month and on
// every day of August: the widest each scope's axis gets.
const POINTS: DiveActivityPoint[] = [
...Array.from({ length: 25 }, (_, index) => ({
year: 2002 + index,
month: 6,
day: 15,
dives: 1 + (index % 4),
})),
...Array.from({ length: 12 }, (_, index) => ({
year: 2025,
month: index + 1,
day: 3,
dives: 2,
})),
...Array.from({ length: 31 }, (_, index) => ({
year: 2025,
month: 8,
day: index + 1,
dives: 1 + (index % 3),
})),
];
const ANCHOR = Date.UTC(2025, 7, 10);
// 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 }}>
<DiveActivityChart
bars={activityBars(POINTS, scope, ANCHOR)}
ceiling={barCeiling(POINTS, scope)}
scope={scope}
/>
</div>,
);
const frame = container.firstElementChild as HTMLElement;
const svg = frame.querySelector("svg") as SVGSVGElement;
return { frame, svg };
}
describe("the dive activity 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 columns = [...svg.querySelectorAll('rect[fill="transparent"]')];
for (const column of [columns[0], columns[columns.length - 1]]) {
fireEvent.mouseEnter(column);
const card = frame.querySelector('[role="presentation"]') as Element;
expect(withinSides(card, frame)).toBe(true);
fireEvent.mouseLeave(column);
}
},
);
});
describe("the dive activity chart under a finger", () => {
const bars = activityBars(POINTS, "year", ANCHOR);
function renderYear() {
const { frame, svg } = renderAt(WIDTHS.phone, "year");
const columns = [...svg.querySelectorAll('rect[fill="transparent"]')];
const card = () => frame.querySelector('[role="presentation"]');
return { svg, columns, card };
}
it("reads the column a tap lands on, and keeps it after the finger lifts", () => {
const { columns, card } = renderYear();
tap(columns[3], centreOf(columns[3]));
expect(card()).toHaveTextContent(bars[3].name);
});
it("scrubs across the columns as the finger drags sideways", () => {
const { columns, card } = renderYear();
fingerDown(columns[1], centreOf(columns[1]));
// Pointer capture keeps every move on the column the finger went down on.
fingerMove(columns[1], centreOf(columns[8]));
expect(card()).toHaveTextContent(bars[8].name);
fingerUp(columns[1], centreOf(columns[8]));
expect(card()).toHaveTextContent(bars[8].name);
});
it("lets go when a finger presses anywhere else", () => {
const { columns, card } = renderYear();
tap(columns[3], centreOf(columns[3]));
pressElsewhere();
expect(card()).toBeNull();
});
it("is not cleared by the mouse events a browser sends after a tap", () => {
const { columns, card } = renderYear();
tap(columns[3], centreOf(columns[3]));
fireEvent.mouseEnter(columns[3]);
fireEvent.mouseLeave(columns[3]);
expect(card()).toHaveTextContent(bars[3].name);
});
it("leaves a vertical swipe to the page", () => {
const { svg } = renderYear();
expect(getComputedStyle(svg).touchAction).toBe("pan-y pinch-zoom");
});
});