src/components/dives/dive-profile-chart.browser.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { DiveProfileChart } from "./dive-profile-chart";
import type { DiveProfile } from "@/lib/api/dives";
import { CHART_FULL_WIDTH_PX } from "@/lib/chart-scale";
import {
overlappingLabels,
sidewaysScrollers,
withinSides,
} from "@/test/chart-layout";
import {
fingerDown,
fingerMove,
fingerUp,
pinch,
pressElsewhere,
tap,
} from "@/test/touch";
// Load-bearing: the browser project loads none of this app's Tailwind, and
// without it a `min-w-*` class computes to nothing, so a chart wider than its
// container would pass every assertion below. The first test fails loudly if
// this import goes.
import "@/app/globals.css";
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ user: { uuid: "user-1", units: "metric" } }),
}));
// A 19 m dive with a full deco panel. Depth's axis ends on 20 and the NDL row's
// begins at 100 min, which is the pair that touched; the time-to-surface, ppO₂
// and percent rows put three more row boundaries under it, and temperature
// labels the right edge.
const times = Array.from({ length: 11 }, (_, index) => index * 300_000);
const PROFILE: DiveProfile = {
duration: 3_000_000,
depth: {
times,
values: [0, 1200, 1900, 1900, 1800, 1700, 1500, 1200, 800, 500, 0],
},
temperature: {
times,
values: [2600, 2500, 2400, 2350, 2320, 2300, 2300, 2320, 2380, 2450, 2520],
},
pressures: [],
ndl: {
times,
values: [5940, 3600, 1800, 1500, 1500, 1600, 1900, 2400, 3600, 5940, 5940],
},
tts: { times, values: [0, 60, 120, 180, 180, 120, 60, 60, 0, 0, 0] },
ppo2: { times, values: [21, 45, 61, 61, 59, 57, 53, 46, 38, 31, 21] },
cns: { times, values: [0, 2, 4, 6, 8, 9, 10, 11, 12, 12, 12] },
gradient_factor: {
times,
values: [0, 10, 25, 38, 45, 52, 58, 60, 55, 40, 20],
},
events: [],
};
// The chart's width on a dive page at a 375 px and a 320 px viewport, and at
// 1024 px, where the page gets its sidebar.
const WIDTHS = { phone: 293, smallPhone: 238, desktop: 582 };
function renderAt(width: number) {
const { container } = render(
<div style={{ width }}>
<DiveProfileChart profile={PROFILE} menuContainer={null} />
</div>,
);
const frame = container.firstElementChild as HTMLElement;
const svg = frame.querySelector("svg") as SVGSVGElement;
return { frame, svg };
}
describe("the dive profile chart's layout", () => {
it("is laid out by the app's stylesheet", () => {
// What makes the rest of this file mean anything - see the import above.
const { svg } = renderAt(WIDTHS.phone);
expect(getComputedStyle(svg.parentElement as Element).position).toBe(
"relative",
);
});
it.each(Object.entries(WIDTHS))(
"draws no label over another at %s width",
(_, width) => {
const { svg } = renderAt(width);
expect(overlappingLabels(svg)).toEqual([]);
},
);
it.each(Object.entries(WIDTHS))(
"fits %s width without scrolling sideways",
(_, width) => {
const { frame, svg } = renderAt(width);
expect(sidewaysScrollers(svg, frame)).toEqual([]);
},
);
it("keeps the type at the size it has at the full-width breakpoint", () => {
// Fitting a phone by shrinking the whole drawing would pass both tests
// above with labels a third of their size.
const { svg } = renderAt(WIDTHS.phone);
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
expect(scale).toBeCloseTo(CHART_FULL_WIDTH_PX / 720);
});
it("keeps the hover card inside the chart wherever the crosshair is", () => {
const { frame, svg } = renderAt(WIDTHS.phone);
const target = svg.querySelector('rect[fill="transparent"]') as Element;
const box = target.getBoundingClientRect();
for (let step = 0; step <= 50; step++) {
fireEvent.mouseMove(target, {
clientX: box.left + (box.width * step) / 50,
clientY: box.top + box.height / 3,
});
const card = frame.querySelector('[role="presentation"]') as Element;
expect(withinSides(card, frame), `at ${step * 2}%`).toBe(true);
}
});
});
describe("the dive profile chart under a finger", () => {
function renderPhone() {
const { frame, svg } = renderAt(WIDTHS.phone);
const target = svg.querySelector('rect[fill="transparent"]') as Element;
const box = target.getBoundingClientRect();
// A point on the plot, as a fraction of the dive.
const at = (fraction: number) => ({
x: box.left + box.width * fraction,
y: box.top + box.height / 3,
});
const card = () => frame.querySelector('[role="presentation"]');
return { svg, at, card };
}
it("reads the instant a tap lands on, and keeps it after the finger lifts", () => {
const { svg, at, card } = renderPhone();
// Two fifths of a 50-minute dive.
tap(svg, at(0.4));
expect(card()).toHaveTextContent("20:00 elapsed");
});
it("scrubs along the dive as the finger drags sideways", () => {
const { svg, at, card } = renderPhone();
fingerDown(svg, at(0.2));
fingerMove(svg, at(0.6));
expect(card()).toHaveTextContent("30:00 elapsed");
fingerUp(svg, at(0.6));
expect(card()).toHaveTextContent("30:00 elapsed");
});
it("lets go when a finger presses anywhere else", () => {
const { svg, at, card } = renderPhone();
tap(svg, at(0.4));
pressElsewhere();
expect(card()).toBeNull();
});
it("is not cleared by the mouse events a browser sends after a tap", () => {
const { svg, at, card } = renderPhone();
const target = svg.querySelector('rect[fill="transparent"]') as Element;
tap(svg, at(0.4));
fireEvent.mouseMove(target, { clientX: at(0.4).x, clientY: at(0.4).y });
fireEvent.mouseLeave(target);
expect(card()).toHaveTextContent("20:00 elapsed");
});
});
describe("the dive profile chart's crosshair on a dive that ended early", () => {
// The same 50-minute dive, recorded on for ten more minutes at the surface.
const RECORDED: DiveProfile = {
...PROFILE,
duration: 3_600_000,
dive_end_time: 3_000_000,
depth: {
times: [...times, 3_300_000, 3_600_000],
values: [...(PROFILE.depth?.values ?? []), 0, 0],
},
};
// Where the pointer is, in client pixels, given the hit target's box.
function hover(at: (box: DOMRect) => number) {
const { container } = render(
<div style={{ width: WIDTHS.phone }}>
<DiveProfileChart profile={RECORDED} menuContainer={null} />
</div>,
);
const target = container.querySelector(
'rect[fill="transparent"]',
) as Element;
const box = target.getBoundingClientRect();
fireEvent.mouseMove(target, {
clientX: at(box),
clientY: box.top + box.height / 3,
});
return container.querySelector('[role="presentation"]');
}
it("reads the dive's end at the plot's right edge", () => {
expect(hover((box) => Math.ceil(box.right))).toHaveTextContent(
"50:00 elapsed",
);
});
it("spreads the dive, not the recording, across the plot", () => {
// Two fifths of the 50-minute dive, give or take the whole pixel the event's
// `clientX` is rounded to; of the hour recorded it would be 24:00.
expect(hover((box) => box.left + box.width * 0.4)?.textContent).toMatch(
/^(19:5\d|20:0\d) elapsed/,
);
});
});
describe("the dive profile chart zoomed by a pinch", () => {
// With the card's title-row slot, so the full-screen button is there to press.
function Card() {
const [slot, setSlot] = useState<HTMLElement | null>(null);
return (
<div style={{ width: WIDTHS.phone }}>
<div ref={setSlot} />
<DiveProfileChart profile={PROFILE} menuContainer={slot} />
</div>
);
}
function renderCard() {
const { container } = render(<Card />);
const svg = () => container.querySelector("svg[role=img]") as Element;
// A point on the plot, as a fraction of the width - of the dive until the
// plot is zoomed, and of the stretch on screen after.
const at = (fraction: number) => {
const box = (
svg().querySelector("[data-hit]") as Element
).getBoundingClientRect();
return {
x: box.left + box.width * fraction,
y: box.top + box.height / 3,
};
};
const card = () => container.querySelector('[role="presentation"]');
// Twice as close, about the middle: 12:30 to 37:30 of the 50-minute dive.
const zoomIn = () => pinch(svg(), [at(0.4), at(0.6)], [at(0.3), at(0.7)]);
return { svg, at, card, zoomIn };
}
it("spreads the stretch between the fingers across the plot", () => {
const { svg, at, card, zoomIn } = renderCard();
zoomIn();
tap(svg(), at(0));
expect(card()).toHaveTextContent("12:30 elapsed");
});
it("drags the plot under a finger that lands away from the crosshair", () => {
const { svg, at, card, zoomIn } = renderCard();
zoomIn();
fingerDown(svg(), at(0.2));
fingerMove(svg(), at(0.4));
fingerUp(svg(), at(0.4));
// Moved, not read out: five minutes earlier is now at the left edge.
expect(card()).toBeNull();
tap(svg(), at(0));
expect(card()).toHaveTextContent("7:30 elapsed");
});
it("drags the crosshair under a finger that lands on it", () => {
const { svg, at, card, zoomIn } = renderCard();
zoomIn();
tap(svg(), at(0.5));
expect(card()).toHaveTextContent("25:00 elapsed");
fingerDown(svg(), at(0.5));
fingerMove(svg(), at(0.7));
expect(card()).toHaveTextContent("30:00 elapsed");
});
it("zooms back out from the button on the title row", () => {
const { svg, at, card, zoomIn } = renderCard();
zoomIn();
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
tap(svg(), at(0));
expect(card()).toHaveTextContent("0:00 elapsed");
});
it("still scrubs under a finger after full screen closes", () => {
const { svg, at, card } = renderCard();
fireEvent.click(screen.getByRole("button", { name: "Full screen" }));
fireEvent.click(screen.getByRole("button", { name: "Exit full screen" }));
fingerDown(svg(), at(0.2));
fingerMove(svg(), at(0.6));
expect(card()).toHaveTextContent("30:00 elapsed");
});
});
describe("the dive profile chart on the dive's shared time axis", () => {
// Two computers on the 50-minute dive, the second switched on ten minutes
// after the first, so the shared axis runs an hour.
const FIRST = { start: 0, end: 3_600_000 };
const LATER = { start: 600_000, end: 3_600_000 };
// Depth every 30 s, so the crosshair reaches a minute and a half past either
// end of the recording rather than the quarter hour `PROFILE`'s cadence gives.
const denseTimes = Array.from({ length: 101 }, (_, index) => index * 30_000);
const DENSE: DiveProfile = {
duration: 3_000_000,
depth: {
times: denseTimes,
values: denseTimes.map((time) =>
time === 0 || time === 3_000_000 ? 0 : 1500,
),
},
pressures: [],
events: [],
};
function Switcher({ shown }: { shown: "first" | "later" }) {
const [slot, setSlot] = useState<HTMLElement | null>(null);
return (
<div style={{ width: WIDTHS.phone }}>
<div ref={setSlot} />
<DiveProfileChart
profile={DENSE}
axis={shown === "first" ? FIRST : LATER}
menuContainer={slot}
/>
</div>
);
}
function renderSwitcher() {
const view = render(<Switcher shown="first" />);
const svg = () => view.container.querySelector("svg[role=img]") as Element;
const at = (fraction: number) => {
const box = (
svg().querySelector("[data-hit]") as Element
).getBoundingClientRect();
return {
x: box.left + box.width * fraction,
y: box.top + box.height / 3,
};
};
const card = () => view.container.querySelector('[role="presentation"]');
const show = (shown: "first" | "later") =>
view.rerender(<Switcher shown={shown} />);
return { svg, at, card, show };
}
it("quotes nothing in the stretch before the later recording began", () => {
const { svg, at, card, show } = renderSwitcher();
show("later");
// Under five minutes in: over five before the later computer's first
// sample, and further than its cadence lets the crosshair reach.
tap(svg(), at(300_000 / 3_600_000 - 0.02));
expect(card()?.textContent).toMatch(/^3:\d\d elapsed— Depth/);
});
it("keeps a zoomed view on the same stretch across a switch", () => {
const { svg, at, card, show } = renderSwitcher();
// Twice as close, about the middle: 15:00 to 45:00 of the hour.
pinch(svg(), [at(0.4), at(0.6)], [at(0.3), at(0.7)]);
tap(svg(), at(0));
expect(card()).toHaveTextContent("15:00 elapsed");
show("later");
tap(svg(), at(0));
expect(card()).toHaveTextContent("15:00 elapsed");
expect(
screen.getByRole("button", { name: "Zoom out" }),
).toBeInTheDocument();
});
});