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(
, ); 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(
, ); 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(null); return (
); } function renderCard() { const { container } = render(); 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(null); return (
); } function renderSwitcher() { const view = render(); 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(); 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(); }); });