import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { useState, type ReactElement } from "react"; import { cleanup, render as renderUi, screen, fireEvent, waitFor, within, } from "@testing-library/react"; import { DiveProfileChart as Chart, type DiveProfileChartProps, } from "./dive-profile-chart"; import { DIVE_PROFILE_SERIES_KEY } from "@/lib/chart-series-view"; import { memoryStorage, useStorage } from "@/test/memory-storage"; import type { DiveProfile, DiveProfileEvent } from "@/lib/api/dives"; import type { UnitSystem } from "@/lib/units"; // These renders read the diver's units, so they need an auth context. Held in a // mutable box rather than a fixed literal so a test can switch systems - `vi.mock`'s // factory is hoisted above the file, and `vi.hoisted` is what lets it close over // something the tests can still reach. const auth = vi.hoisted(() => ({ units: "metric" as UnitSystem })); vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ user: { uuid: "user-1", units: auth.units } }), })); afterEach(() => { auth.units = "metric"; }); // The chart puts its legend in a menu on the card's title row. This stands in // for the card, giving it that slot. function DiveProfileChart(props: Omit) { const [slot, setSlot] = useState(null); return ( <>
); } // Renders with the legend menu open, so its toggles are on screen for every // test that reads or clicks one. function render(ui: ReactElement) { const result = renderUi(ui); const trigger = within(result.container).queryByRole("button", { name: /^Channels/, }); if (trigger) fireEvent.click(trigger); return result; } // The chart's arithmetic is covered in `lib/dive-profile.test.ts`, where it belongs. // What a render adds is the handful of things that are only true once the component // has actually drawn something: that a payload from a newer API doesn't take the page // down, and that the ceiling's shading and the legend agree with each other. function profile(overrides: Partial = {}): DiveProfile { return { duration: 300_000, depth: { times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [0, 1800, 3000, 2400, 800, 0], }, temperature: null, pressures: [], events: [], ...overrides, }; } // A dive long enough for a marker and a curve to be far apart on the axis, with a // depth series spanning all of it so the crosshair always has something to report. function longProfile(overrides: Partial = {}): DiveProfile { return profile({ duration: 3_000_000, depth: { times: [ 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, 2_400_000, 2_700_000, 3_000_000, ], values: [0, 2000, 3000, 3000, 3000, 3000, 3000, 2000, 1000, 500, 0], }, ...overrides, }); } // jsdom gives every element a zero-sized rect, so the hover handler's // `(clientX - left) / width` would be NaN. Stubbing the one measurement it makes is // what turns the crosshair into something assertable - and the crosshair is where // several component-level wirings are only observable. const PLOT = { left: 0, width: 1000 }; // Every channel this chart can plot, all of them drawable, so a test can pick any // subset of them and get exactly that subset back. function everyChannel(overrides: Partial = {}): DiveProfile { const times = [ 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, 2_400_000, 2_700_000, 3_000_000, ]; return { duration: 3_000_000, depth: { times, values: [0, 2000, 3000, 3000, 3000, 3000, 3000, 2000, 1000, 500, 0], }, // Four adjacent samples: an obligation short enough to be one run and long // enough to be drawn, which is what keeps the ceiling in `available`. ceiling: { times: [1_200_000, 1_210_000, 1_220_000, 1_230_000], values: [300, 300, 320, 300], }, temperature: { times, values: [ 2600, 2500, 2400, 2350, 2320, 2300, 2300, 2320, 2380, 2450, 2520, ], }, pressures: [ { gas_number: 1, times, values: [2000, 1850, 1700, 1560, 1420, 1280, 1140, 1000, 880, 800, 760], }, ], // Whole beats per minute, the API's scale of 1. heart_rate: { times, values: [72, 85, 92, 98, 101, 104, 107, 100, 90, 80, 75], }, // The six the computer worked out for itself, on depth's own clock and at // the API's own scales: seconds, hundredths of a bar, tenths of a percent // and whole percent. ndl: { times, values: [5940, 3600, 1800, 900, 300, 0, 0, 0, 0, 0, 0], }, tts: { times, values: [0, 0, 0, 300, 600, 900, 1080, 900, 600, 300, 0] }, ppo2: { times, values: [21, 80, 110, 132, 130, 128, 126, 90, 60, 40, 21] }, cns: { times, values: [0, 10, 24, 40, 58, 77, 96, 110, 118, 122, 124] }, gradient_factor: { times, values: [0, 5, 12, 20, 31, 45, 62, 80, 91, 96, 98], }, surface_gradient_factor: { times, values: [0, 12, 30, 48, 66, 78, 84, 80, 64, 40, 20], }, events: [], ...overrides, }; } // The x of the first point of the first curve a selector matches, in viewBox units. const firstX = (root: HTMLElement, selector: string) => Number( root.querySelector(selector)?.getAttribute("points")?.split(",")[0] ?? Number.NaN, ); // The x of the first point of the first filled area a selector matches. const firstFillX = (root: HTMLElement, selector: string) => Number( /^M(-?[\d.]+),/.exec( root.querySelector(selector)?.getAttribute("d") ?? "", )?.[1] ?? Number.NaN, ); // Depth is drawn as its water column alone, so a drawn depth is a teal fill. const DEPTH_FILL = "path[class*='text-teal']"; // The toggles, addressed the way the legend names them. const CHANNEL_BUTTONS = { depth: /^Depth/, ceiling: /^Deco ceiling/, temperature: /^Temperature/, pressure: /^Tank pressure/, heart_rate: /^Heart rate/, ndl: /^No-deco time/, tts: /^Time to surface/, ppo2: /^ppO₂/, cns: /^CNS/, gradient_factor: /^Gradient factor/, surface_gradient_factor: /^Surface gradient factor/, } as const; type ChannelKey = keyof typeof CHANNEL_BUTTONS; const CHANNEL_KEYS = Object.keys(CHANNEL_BUTTONS) as ChannelKey[]; // The four that can claim an edge of the depth plot. The rest are drawn in the // panel below it, which has a scale of its own per row and takes no // edge from this plot at all. const DEPTH_PLOT_KEYS: readonly ChannelKey[] = [ "depth", "ceiling", "temperature", "pressure", ]; // Every non-empty selection of the channels, as [label, keys] rows for `it.each`. The // empty one is the "all hidden" state and has a test of its own below. const EVERY_SELECTION = Array.from( { length: 2 ** CHANNEL_KEYS.length }, (_, mask) => CHANNEL_KEYS.filter((_, index) => mask & (1 << index)), ) .filter((keys) => keys.length > 0) .map((keys) => [keys.join(" + "), keys] as const); // The numbers running down either edge of the **depth plot**. The elapsed-time // ticks anchor `middle`, so the anchors are exactly the vertical axes - and the // panel's rows carry their own left-hand numbers, which are a different // question and are excluded here rather than being allowed to answer this one. const outsideThePanel = (tick: Element) => !tick.closest("[data-panel]"); const leftAxisTicks = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='end']")] .filter(outsideThePanel) .map((tick) => tick.textContent); const rightAxisTicks = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='start']")] .filter(outsideThePanel) .map((tick) => tick.textContent); // The plot's horizontal extent in viewBox units, read off the hit target laid // over it: each edge's gutter is sized to the labels drawn in it. function plotEdges(root: HTMLElement) { const target = root.querySelector('rect[fill="transparent"]'); const left = Number(target?.getAttribute("x")); const width = Number(target?.getAttribute("width")); return { left, width, right: left + width }; } // The panel's rows that are actually on screen, by the axis each carries. const panelRows = (root: HTMLElement) => [...root.querySelectorAll("[data-panel]")].map((row) => row.getAttribute("data-panel"), ); // The chart remembers which channels were last plotted, so the tests below that // seed or toggle a selection need a store of their own - and `window.localStorage` // doesn't work under this runner at all without it. See `test/memory-storage.ts`. beforeEach(() => { useStorage(memoryStorage()); }); afterEach(() => { vi.restoreAllMocks(); }); function hoverAt(fractionOfDive: number) { vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ left: PLOT.left, width: PLOT.width, top: 0, height: 200, right: PLOT.width, bottom: 200, x: 0, y: 0, toJSON: () => ({}), } as DOMRect); // The transparent hit target laid over the plot. const target = document.querySelector( 'rect[fill="transparent"]', ); if (!target) throw new Error("no hit target - the chart drew nothing"); fireEvent.mouseMove(target, { clientX: PLOT.left + PLOT.width * fractionOfDive, clientY: 100, }); } // What the crosshair is currently saying, or "" when it is saying nothing at all - // the tooltip is absent, not empty, when no channel and no marker is in reach. function readoutText(): string { return document.querySelector("[data-chart-readout]")?.textContent ?? ""; } describe("DiveProfileChart on the millisecond axis", () => { // Every time on the wire is milliseconds from the recording's own start, so a // Suunto app export's first depth - stamped 160 ms after its header in // `suunto-ocean.json` - sits 160 ms along the axis rather than at its origin. const suuntoOcean = profile({ duration: 4_000_020, depth: { times: [160, 1_200_000, 4_000_020], values: [120, 2400, 0] }, }); it("draws a sample at the sub-second offset its export states", () => { const { container } = render(); // 160 ms of a 4 000 020 ms profile along the plot - a hair right of the // axis, and not on it. const plot = plotEdges(container); const x = firstFillX(container, DEPTH_FILL); expect(x).toBeGreaterThan(plot.left); expect(x).toBeCloseTo(plot.left + (160 / 4_000_020) * plot.width, 6); }); it("labels the elapsed axis in minutes and seconds", () => { const { container } = render(); const labels = [ ...container.querySelectorAll("text[text-anchor='middle']"), ].map((label) => label.textContent); expect(labels).toEqual([ "0:00", "10:00", "20:00", "30:00", "40:00", "50:00", ]); }); it("says the crosshair's instant in minutes and seconds", () => { render(); hoverAt(0.5); expect(readoutText()).toMatch(/^25:00 elapsed/); }); it("reads the span aloud in minutes", () => { render(); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /^Dive profile over 50min,/, ); }); }); describe("DiveProfileChart in imperial", () => { // The conversion happens once, where the wire scale is divided out, so what the // legend, the crosshair and the accessible summary all read is already in feet. const imperialDive = profile({ // Sampled on depth's own clock, so the crosshair has a reading of each to // quote at the moment it is over. temperature: { times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [2500, 2450, 2400, 2380, 2360, 2350], }, pressures: [ { gas_number: 1, times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [2000, 1800, 1600, 1400, 1200, 1000], }, ], }); it("names each channel's imperial unit in the legend", () => { auth.units = "imperial"; render(); expect(screen.getByRole("button", { name: "Depth (ft)" })).toBeTruthy(); expect( screen.getByRole("button", { name: "Temperature (°F)" }), ).toBeTruthy(); expect( screen.getByRole("button", { name: "Tank pressure (psi)" }), ).toBeTruthy(); }); it("quotes whole imperial readings in the crosshair", () => { auth.units = "imperial"; render(); hoverAt(120 / 300); // 30 m is 98 ft, 24 °C is 75 °F - and the degree is attached, as it is // everywhere else in the app. expect(readoutText()).toMatch(/98 ft Depth/); expect(readoutText()).toMatch(/75°F Temperature/); }); it("spells the imperial units out in the accessible summary", () => { auth.units = "imperial"; render(); const summary = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(summary).toMatch(/maximum depth 98 feet/); expect(summary).toMatch(/degrees Fahrenheit/); expect(summary).toMatch(/psi/); }); }); describe("DiveProfileChart quoting a temperature", () => { // Hundredths on the wire whatever the source stated, so a coarser source // arrives with its last digits zero; the crosshair and the summary quote the // grain the recording's readings carry, never the wire's. const dive = (values: number[]) => profile({ temperature: { times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values, }, }); it.each([ [ "hundredths", [2711, 2705, 2690, 2669, 2672, 2680], "26.90°C", "temperature 26.69 to 27.11 degrees Celsius", ], [ "tenths", [2360, 2350, 2340, 2330, 2330, 2340], "23.4°C", "temperature 23.3 to 23.6 degrees Celsius", ], [ "whole degrees", [2700, 2700, 2600, 2600, 2600, 2700], "26°C", "temperature 26 to 27 degrees Celsius", ], ])("quotes a recording in %s at that grain", (_, values, reading, spoken) => { render(); hoverAt(120 / 300); expect(readoutText()).toContain(`${reading} Temperature`); expect(screen.getByRole("img").getAttribute("aria-label")).toContain( spoken, ); }); it("quotes whole degrees Fahrenheit whatever the grain", () => { auth.units = "imperial"; render( , ); hoverAt(120 / 300); // 26.90 °C is 80.42 °F. expect(readoutText()).toContain("80°F Temperature"); }); }); describe("DiveProfileChart with an event type this build doesn't know", () => { // `ProfileEventType` is closed on the API side *today*, and the two repos deploy // independently - so an API that grows a sixth type reaches browsers still running // this bundle, where `event.type` is a string TypeScript merely believes is one of // five. Indexing the glyph table with it yields `undefined`, and destructuring that // throws inside render, which trades the whole dive page for `app/error.tsx` rather // than losing one tick - so `glyphFor` falls back instead. const rogue = (label?: string) => ({ time: 120_000, type: "ndl_violation", label, }) as unknown as DiveProfileEvent; it("renders the chart instead of throwing", () => { expect(() => render( , ), ).not.toThrow(); expect(screen.getByRole("img")).toBeInTheDocument(); }); it("degrades to the device's own wording in the accessible summary", () => { render( , ); // Not the string "undefined", which is what a `switch` with no `default` // returned from a function typed `: string`. const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(name).toContain("NDL Violation"); expect(name).not.toContain("undefined"); }); it("still says something when the unknown type carries no label either", () => { render(); const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(name).toContain("Device event"); expect(name).not.toContain("undefined"); }); }); describe("DiveProfileChart ceiling", () => { const withCeiling = profile({ // Two runs with a real gap between them: an obligation that cleared and came // back. The chart must shade each separately rather than spanning the middle, // where the diver owed nothing. ceiling: { times: [60_000, 70_000, 80_000, 200_000, 210_000, 220_000], values: [300, 320, 340, 300, 310, 320], }, }); it("shades one region per run of consecutive samples", () => { const { container } = render(); const shaded = container.querySelectorAll("path[class*='text-ceiling']"); expect(shaded).toHaveLength(2); }); it("draws the ceiling dashed, and depth as a fill with no line", () => { const { container } = render(); const dashed = container.querySelectorAll("polyline[stroke-dasharray]"); const solid = container.querySelectorAll( "polyline:not([stroke-dasharray])", ); expect(dashed).toHaveLength(2); // one per ceiling run expect(solid).toHaveLength(0); expect(container.querySelectorAll(DEPTH_FILL).length).toBeGreaterThan(0); }); it("gives the legend a dashed swatch for the ceiling and solid ones elsewhere", () => { render(); const swatch = (name: RegExp) => screen.getByRole("button", { name }).querySelector("span")?.className ?? ""; // The legend is the only thing that says what the red region means, so a solid // swatch beside a dashed curve is the one thing it must not say. expect(swatch(/Deco ceiling/)).toContain("border-dashed"); expect(swatch(/Depth/)).not.toContain("border-dashed"); }); it("offers no ceiling toggle on a dive that owed no decompression", () => { render(); expect( screen.queryByRole("button", { name: /Deco ceiling/ }), ).not.toBeInTheDocument(); }); }); describe("DiveProfileChart crosshair over a short deco obligation", () => { // The wiring the `lib` tests can't reach. `sampleIndexAt` and `readoutTolerance` // are covered there, but which of `gapThreshold`/`readoutTolerance` the chart // actually hands to the readout is a fact about this file - and feeding it the // former is precisely how the guard shipped inert the first time. // // Two samples ten seconds apart: a dive that tipped into deco briefly, which // still draws (they are inside the 15 s floor) and still reads out. const shortObligation = longProfile({ ceiling: { times: [1_400_000, 1_410_000], values: [300, 300] }, }); it("does not quote the ceiling from the far side of the dive", () => { render(); hoverAt(0.05); // 150 s, twenty minutes before the obligation existed // The row stays, with a dash where a reading would be. expect(readoutText()).toMatch(/— Deco ceiling/); }); it("still quotes it while the crosshair is on it", () => { render(); hoverAt(1405 / 3000); expect(readoutText()).toMatch(/3\.0 m Deco ceiling/); }); it("names a marker the crosshair is on, and none when it is away from one", () => { // The other half of the same class of wiring: `nearestEvent` is covered in // `lib`, but the tolerance the chart hands it - // `(duration / plotWidth) * EVENT_HOVER_UNITS`, about 38 s on this dive at the // design width jsdom always draws - is a fact about this file and nothing // asserted it. render( , ); hoverAt(900 / 3000); expect(readoutText()).toMatch(/Bookmark/); hoverAt(1500 / 3000); expect(readoutText()).not.toMatch(/Bookmark/); }); }); describe("DiveProfileChart shading a brief obligation", () => { // The drawing half of the short-series problem, and the one the readout fix // missed. `gapThreshold` answers Infinity below three samples, which joins two // isolated moments in deco into a single run - so the chart shaded twenty // minutes of forbidden zone across a stretch the diver owed nothing, while the // crosshair (already floored to 15 s) reported no ceiling there at all. const twoMoments = longProfile({ ceiling: { times: [1_400_000, 2_600_000], values: [300, 300] }, }); it("does not span twenty minutes between two isolated deco samples", () => { const { container } = render(); // Two single-point runs draw neither an area nor a line. Silence is the // honest answer to an obligation that lasted one sample; the card's // description still reports the dive had a ceiling, from `max_ceiling`. expect( container.querySelectorAll("path[class*='text-ceiling']"), ).toHaveLength(0); expect( container.querySelectorAll("polyline[stroke-dasharray]"), ).toHaveLength(0); }); it("still draws a brief obligation whose samples are adjacent", () => { // The case worth drawing, and the one a blanket "drop short series" would // have lost: two consecutive 10 s samples are 10 s apart, inside the 15 s // floor, so they remain one run. const { container } = render( , ); expect( container.querySelectorAll("path[class*='text-ceiling']"), ).toHaveLength(1); }); it("agrees with the crosshair about where the obligation was", () => { // The property `gapMs` exists for: the two halves are cut at one // threshold, so there is no stretch that is shaded but unquotable. render(); hoverAt(2000 / 3000); // mid-span, between the two isolated samples expect(readoutText()).not.toMatch(/Deco ceiling/); }); it("quotes nothing while hovering the isolated sample itself", () => { // Mid-span was the easy half, and testing only that is how this survived a // round: `gapMs` still resolved an index within 15 s of a sample that // had been dropped from the drawing, so the crosshair named a 3.0 m ceiling // and planted a dot on it over a chart with no ceiling on it. A ~5 px band, // narrower than the whole dive and no less wrong. const { container } = render(); hoverAt(1400 / 3000); // exactly on the first isolated sample expect(readoutText()).not.toMatch(/Deco ceiling/); expect( container.querySelectorAll("circle[class*='text-ceiling']"), ).toHaveLength(0); }); it("keeps the summary agreeing with the picture", () => { render(); // A chart that drew no ceiling must not announce one either. expect( screen.getByRole("img").getAttribute("aria-label") ?? "", ).not.toMatch(/deco ceiling/i); }); it("offers no toggle for a channel too sparse to draw", () => { render(); expect( screen.queryByRole("button", { name: /Deco ceiling/ }), ).not.toBeInTheDocument(); }); }); describe("DiveProfileChart summary over a partly-drawn channel", () => { // The granularity gap the channel filter didn't reach. `shownValues` gates at // channel level - is this channel on the chart - while runs are dropped at // sample level, so a ceiling that keeps one drawable run still handed its whole // raw series to the summary and to the shared axis. Here 2000 s is an isolated // 9.0 m sample that draws nothing, while the drawn run tops out at 3.0 m. const partlyDrawn = longProfile({ ceiling: { times: [600_000, 610_000, 620_000, 2_000_000], values: [300, 300, 300, 900], }, }); it("quotes the deepest ceiling that was drawn, not the deepest recorded", () => { render(); const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; // An invented deeper obligation is a safety claim, which is the argument // this file makes for itself everywhere else. expect(name).toContain("deco ceiling to 3.0 meters"); expect(name).not.toContain("9.0 meters"); }); it("does not stretch the shared axis to an undrawn sample", () => { const { container } = render(); // Depth tops out at 30 m here, so a domain reaching past ~30 could only be // accommodating the 9 m ceiling sample nothing drew... which is inside the // depth range anyway. The assertion that bites: the drawn ceiling sits where // 3 m sits on the depth axis, unmoved by the 9 m sample. const dashed = container.querySelector("polyline[stroke-dasharray]"); const ys = (dashed?.getAttribute("points") ?? "") .split(" ") .map((point) => Number(point.split(",")[1])); // 3 m on a 0-30 m axis over a 238-unit plot: 14 + (3/30)*238 ≈ 37.8. expect(ys.every((y) => Math.abs(y - 37.8) < 0.5)).toBe(true); }); it("still reads out the drawn run", () => { render(); hoverAt(610 / 3000); expect(readoutText()).toMatch(/3\.0 m Deco ceiling/); }); it("quotes nothing at the undrawn sample", () => { render(); hoverAt(2000 / 3000); expect(readoutText()).toMatch(/— Deco ceiling/); }); }); describe("DiveProfileChart with a two-sample measured channel", () => { // Refusing to join two distant samples is right for the ceiling and wrong // everywhere else: a gap in a measured channel means "not recorded", two // samples are still two readings, and filtering them out took the whole chart // with them when depth was the only channel. it("still plots a depth series of two samples", () => { const { container } = render( , ); expect(container.textContent).not.toContain("recorded no samples to plot"); expect(container.querySelectorAll(DEPTH_FILL)).toHaveLength(1); expect(screen.getByRole("button", { name: /Depth/ })).toBeInTheDocument(); }); it("still plots a temperature series of two samples", () => { render( , ); expect( screen.getByRole("button", { name: /Temperature/ }), ).toBeInTheDocument(); expect(screen.getByRole("img").getAttribute("aria-label") ?? "").toMatch( /temperature/i, ); }); }); describe("DiveProfileChart with a ceiling too sparse to plot", () => { // Only the ceiling refuses to join distant samples, so only the ceiling can end // up with nothing drawable. When it does, it must not claim a legend entry, an // axis in its own colour, or a line in the summary. const sparseCeiling = longProfile({ ceiling: { times: [100_000, 1_600_000], values: [300, 300] }, }); it("draws no curve, and says so consistently everywhere", () => { const { container } = render(); expect( container.querySelectorAll("polyline[stroke-dasharray]"), ).toHaveLength(0); expect( container.querySelectorAll("path[class*='text-ceiling']"), ).toHaveLength(0); expect( screen.queryByRole("button", { name: /Deco ceiling/ }), ).not.toBeInTheDocument(); expect( screen.getByRole("img").getAttribute("aria-label") ?? "", ).not.toMatch(/deco ceiling/i); }); it("leaves the depth axis where depth alone would put it", () => { // The ceiling's undrawn samples must not stretch the axis they share. const { container } = render(); const withoutCeiling = render(); const ticks = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='end']")].map( (label) => label.textContent, ); expect(ticks(container)).toEqual(ticks(withoutCeiling.container)); }); }); // How many distinct scales a selection puts on the **depth plot**. Depth and the // ceiling share one domain by construction; temperature and pressure each have // their own, and the channels in the panel put none there at all - so this is the // number of edges that should end up labelled, and the plot has two. function distinctScales(keys: readonly ChannelKey[]): number { return new Set( keys .filter((key) => DEPTH_PLOT_KEYS.includes(key)) .map((key) => (key === "ceiling" ? "depth" : key)), ).size; } describe("DiveProfileChart depth fill across a dropout", () => { // A depth series with a real hole in it: `gapThreshold` is three times the median // delta with a 15 s floor, so a 300 s cadence breaks at anything past 900 s and // this 1 500 s hole is well clear of it. Modelled on `Dive_2025-03-08-1440.xml`, // which has a 1 341-second one. const withDropout = profile({ duration: 3_000_000, depth: { times: [0, 300_000, 600_000, 900_000, 2_400_000, 2_700_000, 3_000_000], values: [0, 2000, 3000, 3000, 1000, 500, 0], }, }); it("breaks the fill at the dropout", () => { // A fill built from the whole series runs straight across the gap - a filled // region claiming the diver was in water the device recorded nothing about. const { container } = render(); expect(container.querySelectorAll(DEPTH_FILL)).toHaveLength(2); }); it("leaves the dropout unfilled rather than spanning it", () => { // The count above would also pass on two paths that happened to overlap, so // this asserts the geometry: each fill starts and ends inside its own run, and // neither reaches across the hole between 900 s and 2 400 s. const { container } = render(); const spans = [...container.querySelectorAll("path[class*='text-teal']")] .map((fill) => fill.getAttribute("d") ?? "") .map((d) => { const xs = [...d.matchAll(/[ML](-?[\d.]+),/g)].map((match) => Number(match[1]), ); return [Math.min(...xs), Math.max(...xs)]; }) .sort((first, second) => first[0] - second[0]); // 900 s and 2 400 s on a 3 000 s dive. const plot = plotEdges(container); expect(spans[0][1]).toBeCloseTo(plot.left + 0.3 * plot.width, 0); expect(spans[1][0]).toBeCloseTo(plot.left + 0.8 * plot.width, 0); }); it("still draws one fill for a series with no dropout in it", () => { const { container } = render(); expect(container.querySelectorAll("path[class*='text-teal']")).toHaveLength( 1, ); }); }); describe("DiveProfileChart vertical axes", () => { // The reported bug was one cell of this table: with depth and the ceiling both // switched off, the left-hand labels vanished and the plot was left with a blank // margin and gridlines running out of it. Swept over every non-empty selection // of the channels rather than pinned at that one, because "which channel // labels which edge" is a rule whose failing combination was not the obvious // one - and the ones that take no edge are swept too, since "this selection // leaves both edges alone" is as much a cell of the table as the other kind. // // Seeded through the remembered selection rather than by clicking, which is the // same path a returning diver takes and needs no click sequence per cell. it.each(EVERY_SELECTION)( "reads %s off the left edge, and the right only for a second scale", (_, keys) => { window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(keys), ); const { container } = render( , ); expect(leftAxisTicks(container).length > 0).toBe( distinctScales(keys) > 0, ); expect(rightAxisTicks(container).length > 0).toBe( distinctScales(keys) > 1, ); }, ); it.each(EVERY_SELECTION)( "gives %s a labelled scale wherever it is drawn", (_, keys) => { // No curve is drawn against nothing. A panel channel the diver switched on // is in a panel row, and that row carries its own numbers - which is the // half of the rule the depth plot's two edges could never have satisfied. window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(keys), ); const { container } = render( , ); const expectedRows = [ keys.includes("heart_rate") ? "heart_rate" : null, keys.includes("ndl") ? "no_deco" : null, keys.includes("tts") ? "time_to_surface" : null, keys.includes("ppo2") ? "ppo2" : null, keys.some((key) => ["cns", "gradient_factor", "surface_gradient_factor"].includes(key), ) ? "percent" : null, ].filter((axis) => axis !== null); expect(panelRows(container)).toEqual(expectedRows); for (const axis of expectedRows) { const row = container.querySelector(`[data-panel="${axis}"]`); expect( row?.querySelectorAll("text[text-anchor='end']").length, ).toBeGreaterThan(0); } }, ); it("leaves the right edge empty where the selection has one scale", () => { // Depth and the ceiling share a domain, so there is no second scale to put // there. It stays empty rather than repeating the left's numbers - two columns // of identical figures invite the reading that they are two scales. window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["depth", "ceiling"]), ); const { container } = render(); expect(leftAxisTicks(container).length).toBeGreaterThan(0); expect(rightAxisTicks(container)).toEqual([]); }); it("gives the second scale the right-hand edge when there is one", () => { // And then they must not be the same numbers: meters on one side, °C on the // other. window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["depth", "temperature"]), ); const { container } = render(); expect(rightAxisTicks(container).length).toBeGreaterThan(0); expect(rightAxisTicks(container)).not.toEqual(leftAxisTicks(container)); }); // Which colour each edge is labelled in, which is how the chart says which // channel owns it: teal depth, red ceiling, coral temperature, grey pressure. const axisColours = (root: HTMLElement) => ({ left: root.querySelector("text[text-anchor='end']")?.getAttribute("class") ?? "", right: root.querySelector("text[text-anchor='start']")?.getAttribute("class") ?? "", }); function seeded(keys: readonly ChannelKey[]) { window.localStorage.setItem(DIVE_PROFILE_SERIES_KEY, JSON.stringify(keys)); return axisColours( render().container, ); } // The sides are fixed so that a diver reading across a logbook of dives finds // the meters where they left them. Hue is a slower thing to check than // position, so an axis that swapped edges with the channel mix would make them // re-read the colour of the numbers on every dive. it("keeps meters on the left and temperature on the right", () => { expect(seeded(["depth", "temperature"])).toEqual({ left: expect.stringContaining("text-teal"), right: expect.stringContaining("text-coral"), }); }); it("keeps the ceiling on depth's own edge when depth is off", () => { expect(seeded(["ceiling", "temperature"])).toEqual({ left: expect.stringContaining("text-ceiling"), right: expect.stringContaining("text-coral"), }); }); it("sends pressure to the right when meters hold the left", () => { // Pressure is the channel that moves, and it is the right one to move: on a // full three-scale plot it is the one that goes unlabelled anyway. expect(seeded(["depth", "pressure"])).toEqual({ left: expect.stringContaining("text-teal"), right: expect.stringContaining("text-pressure"), }); }); it("sends pressure to the left when temperature holds the right", () => { // The originally reported selection: depth and the ceiling both off. Neither // edge was labelled for pressure before, and the left was blank. expect(seeded(["temperature", "pressure"])).toEqual({ left: expect.stringContaining("text-pressure"), right: expect.stringContaining("text-coral"), }); }); it("leaves pressure unlabelled where both edges are taken", () => { expect(seeded(["depth", "temperature", "pressure"])).toEqual({ left: expect.stringContaining("text-teal"), right: expect.stringContaining("text-coral"), }); }); it("puts a lone temperature scale on the left, where a lone scale goes", () => { // The one place the fixed sides give way: with nothing sharing the plot there // is no side to protect, and a blank left gutter is what both reports were // about. expect(seeded(["temperature"])).toEqual({ left: expect.stringContaining("text-coral"), right: "", }); }); }); describe("DiveProfileChart with every channel switched off", () => { // Pins that the plot stays: replacing it with a sentence collapses the card to // two lines and pulls the legend - the only way back - up the page away from // the cursor that has just clicked it. function hideEverything() { const view = render(); for (const name of Object.values(CHANNEL_BUTTONS)) { fireEvent.click(screen.getByRole("button", { name })); } return view; } it("keeps the plot, and says why it is empty", () => { hideEverything(); expect(screen.getByRole("img")).toBeInTheDocument(); expect(screen.getByText(/every channel is hidden/i)).toBeInTheDocument(); }); it("leaves the toggles where they were", () => { hideEverything(); for (const name of Object.values(CHANNEL_BUTTONS)) { expect(screen.getByRole("button", { name })).toHaveAttribute( "aria-pressed", "false", ); } }); it("draws no curve, no fill and no vertical axis", () => { // An empty plot, not a plot of nothing labelled as if it were something: with // no channel on screen there is no domain, so neither edge can carry numbers. const { container } = hideEverything(); expect(container.querySelectorAll("polyline")).toHaveLength(0); expect( container.querySelectorAll("path[fill='currentColor']"), ).toHaveLength(0); expect(leftAxisTicks(container)).toEqual([]); expect(rightAxisTicks(container)).toEqual([]); }); it("still draws the elapsed-time axis, which no channel was holding up", () => { const { container } = hideEverything(); expect( container.querySelectorAll("text[text-anchor='middle']").length, ).toBeGreaterThan(0); }); }); describe("DiveProfileChart markers past the end of the recorded profile", () => { // The API clamps event times at zero and leaves the high end alone // (`shape_events`), so the chart clips. Undrawn, 6 000 s on a 3 000 s dive // lands a whole plot's width past the viewBox; 3 200 s lands just past the // plot's right edge, in the right-hand axis-label gutter or the card's padding, // aligned with no time on the axis. const late = longProfile({ events: [ { time: 1_500_000, type: "bookmark" }, { time: 3_200_000, type: "bookmark" }, { time: 6_000_000, type: "gas_switch", gas_number: 1 }, ], }); it("draws only the markers that fall inside the plot", () => { const { container } = render(); const ticks = [ ...container.querySelectorAll("g[aria-hidden][opacity] line"), ]; expect(ticks).toHaveLength(1); expect(Number(ticks[0].getAttribute("x1"))).toBeLessThanOrEqual( plotEdges(container).right, ); }); it("does not name in the summary what it declined to draw", () => { // The actual defect: a marker invisible to the eye but announced to a screen // reader is the two views disagreeing about what the chart contains. render(); const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(name).toContain("Bookmark at 25min"); expect(name).not.toContain("53min"); expect(name).not.toContain("1h 40min"); }); }); describe("DiveProfileChart ending at the dive's end", () => { // A 50-minute dive and ten more minutes of the recorder at the surface, where // the water is far warmer than anything the dive went through. Temperature has // no sample at the end, so it stops short of it. const surfaceTimes = [3_000_000, 3_300_000, 3_600_000]; const recorded = (end: number | null | undefined): DiveProfile => longProfile({ duration: 3_600_000, dive_end_time: end, depth: { times: [ 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, 2_400_000, 2_700_000, ...surfaceTimes, ], values: [ 0, 2000, 3000, 3000, 3000, 3000, 3000, 2000, 1000, 500, 90, 0, 0, ], }, temperature: { times: [ 150_000, 750_000, 1_350_000, 1_950_000, 2_550_000, 3_150_000, 3_450_000, ], values: [2600, 2450, 2350, 2320, 2380, 3200, 3300], }, events: [ { time: 1_500_000, type: "bookmark" }, { time: 3_300_000, type: "pressure_low" }, ], }); const dive = recorded(3_000_000); // Every x a curve or a fill is drawn through, in viewBox units. const curveXs = (root: HTMLElement) => [...root.querySelectorAll("polyline, path")] .filter((shape) => shape.closest("svg[role='img']")) .flatMap((shape) => [ ...( shape.getAttribute("points") ?? shape.getAttribute("d") ?? "" ).matchAll(/(-?[\d.]+),-?[\d.]+/g), ].map((match) => Number(match[1])), ); const elapsedLabels = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='middle']")].map( (label) => label.textContent, ); it("draws nothing right of the plot, and depth reaches its edge", () => { const { container } = render(); const xs = curveXs(container); expect(xs.length).toBeGreaterThan(0); expect(Math.max(...xs)).toBeCloseTo(plotEdges(container).right, 6); }); it("stops temperature at its last sample before the end, on the dive's axis", () => { const { container } = render(); const xs = [...container.querySelectorAll("polyline")].flatMap((line) => [...(line.getAttribute("points") ?? "").matchAll(/(-?[\d.]+),/g)].map( (match) => Number(match[1]), ), ); // 2 550 000 ms of a 3 000 000 ms axis. const plot = plotEdges(container); expect(Math.max(...xs)).toBeCloseTo( plot.left + (2_550_000 / 3_000_000) * plot.width, 1, ); }); it("fits the temperature axis without the surface stretch", () => { const highest = (profile: DiveProfile) => { const { container, unmount } = render( , ); const ticks = rightAxisTicks(container).map((tick) => Number.parseFloat(tick ?? ""), ); unmount(); return Math.max(...ticks); }; expect(highest(dive)).toBeLessThan(30); // The same recording with no end reaches past it, so the line above is // measuring the cut rather than a scale that never went that far. expect(highest(recorded(undefined))).toBeGreaterThanOrEqual(32); }); it("puts no elapsed tick past the end", () => { const { container } = render(); expect(elapsedLabels(container)).toEqual([ "0:00", "10:00", "20:00", "30:00", "40:00", "50:00", ]); }); it("neither draws nor names a marker past the end", () => { const { container } = render(); expect( container.querySelectorAll("g[aria-hidden][opacity] line"), ).toHaveLength(1); const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(name).toContain("Bookmark at 25min"); expect(name).not.toContain("55min"); }); it("speaks the dive's span in the summary", () => { render(); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /^Dive profile over 50min,/, ); }); it("draws the whole recording where the response names no end", () => { const { container } = render(); expect(elapsedLabels(container)).toContain("60:00"); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /^Dive profile over 1h/, ); }); }); describe("DiveProfileChart naming cylinders", () => { // Labels count from 0 and the Tanks card numbers its tanks from 1, so a bare // label would name the card's next tank. The chart names the joined tank by the // card's position, and a label no cylinder carries by the device's label with // the word kept. const times = [0, 1_000_000, 2_000_000, 3_000_000]; const twoTanks = longProfile({ pressures: [ { gas_number: 0, times, values: [2000, 1700, 1400, 1100] }, { gas_number: 7, times, values: [2000, 2000, 1800, 1600] }, ], events: [ { time: 600_000, type: "gas_switch", gas_number: 1 }, { time: 2_400_000, type: "gas_switch", gas_number: 7 }, ], }); const mixtures = [{ gas_number: 0 }, { gas_number: 1 }]; it("names a pressure curve by the tank its label joins to", () => { render(); hoverAt(0.5); expect(readoutText()).toContain("Tank pressure (tank 1)"); expect(readoutText()).toContain("Tank pressure (gas 7)"); }); it("names a gas switch by the tank its label joins to", () => { render(); const name = screen.getByRole("img").getAttribute("aria-label") ?? ""; expect(name).toContain("Gas switch to tank 2 at 10min"); expect(name).toContain("Gas switch to gas 7 at 40min"); }); }); describe("DiveProfileChart marker toggle", () => { // Markers are switchable rather than always drawn: on a dive that carries a // dozen, a row of ticks along the baseline is a picket fence in front of the // curves it annotates. const withMarkers = longProfile({ events: [ { time: 600_000, type: "gas_switch", gas_number: 2 }, { time: 1_500_000, type: "bookmark" }, { time: 2_400_000, type: "safety_stop" }, ], }); const markerTicks = (root: HTMLElement) => root.querySelectorAll("g[aria-hidden][opacity] line"); const markerToggle = () => screen.getByRole("button", { name: /^Markers/ }); it("offers the toggle, on, for a dive that has markers", () => { render(); expect(markerToggle()).toHaveAttribute("aria-pressed", "true"); expect(markerTicks(document.body)).toHaveLength(3); }); it("offers none for a dive that has none", () => { // A dive with no markers gets no switch for them, exactly as a dive that owed // no decompression gets no ceiling switch. render(); expect( screen.queryByRole("button", { name: /^Markers/ }), ).not.toBeInTheDocument(); }); it("takes the markers off the plot when switched off", () => { const { container } = render(); fireEvent.click(markerToggle()); expect(markerTicks(container)).toHaveLength(0); expect(markerToggle()).toHaveAttribute("aria-pressed", "false"); }); it("stops naming them in the accessible summary too", () => { // The disagreement this chart has had to be talked out of repeatedly: a mark // the eye cannot see must not still be announced to a screen reader. render(); expect(screen.getByRole("img").getAttribute("aria-label")).toContain( "Bookmark", ); fireEvent.click(markerToggle()); expect(screen.getByRole("img").getAttribute("aria-label")).not.toContain( "Bookmark", ); }); it("stops quoting them in the crosshair too", () => { render(); hoverAt(1500 / 3000); expect(readoutText()).toMatch(/Bookmark/); fireEvent.click(markerToggle()); hoverAt(1500 / 3000); expect(readoutText()).not.toMatch(/Bookmark/); // And the depth reading at that instant is untouched - the toggle hides the // annotations, not the readout they happened to coincide with. expect(readoutText()).toMatch(/Depth/); }); it("leaves the curves alone", () => { const { container } = render(); fireEvent.click(markerToggle()); expect( container.querySelectorAll(`polyline, ${DEPTH_FILL}`).length, ).toBeGreaterThan(0); expect(leftAxisTicks(container).length).toBeGreaterThan(0); }); it("remembers the choice for the next dive", () => { // Through the same stored entry as the channels, which is what makes the // markers switch feel like the four beside it rather than a per-page mode. render(); fireEvent.click(markerToggle()); expect( JSON.parse(window.localStorage.getItem(DIVE_PROFILE_SERIES_KEY) ?? "[]"), ).not.toContain("events"); }); it("restores a remembered selection that hid them", () => { window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["depth", "ceiling", "temperature", "pressure"]), ); const { container } = render(); expect(markerTicks(container)).toHaveLength(0); expect(markerToggle()).toHaveAttribute("aria-pressed", "false"); }); it("keeps the markers up with every channel hidden, and holds the message back", () => { // A plot showing only markers has content, so "Every channel is hidden. Pick // one from the menu to plot it." printed across them would be describing a // chart nobody is looking at. const { container } = render(); for (const name of Object.values(CHANNEL_BUTTONS)) { const toggle = screen.queryByRole("button", { name }); if (toggle) fireEvent.click(toggle); } expect(markerTicks(container)).toHaveLength(3); expect( screen.queryByText(/every channel is hidden/i), ).not.toBeInTheDocument(); }); }); describe("DiveProfileChart selection across dives", () => { // The remembered selection is one entry shared by every dive, while `available` // is per-dive - so anything that narrows the stored value to *this* dive's keys // erases the diver's opinion about the ones it happens to lack. That is the very // failure the `-v3` key bump exists to fix once, at migration; writing a narrowed // selection back would reopen it on every dive that lacks a key. const noMarkers = longProfile({ temperature: { times: [0, 300_000, 600_000, 900_000], values: [2600, 2500, 2400, 2350], }, }); const withMarkers = longProfile({ temperature: { times: [0, 300_000, 600_000, 900_000], values: [2600, 2500, 2400, 2350], }, events: [{ time: 600_000, type: "bookmark" }], }); it("does not let a marker-less dive switch the markers off for the next one", () => { render(); fireEvent.click(screen.getByRole("button", { name: /^Temperature/ })); cleanup(); render(); expect( document.querySelectorAll("g[aria-hidden][opacity] line"), ).toHaveLength(1); expect(screen.getByRole("button", { name: /^Markers/ })).toHaveAttribute( "aria-pressed", "true", ); }); it("does not let a no-deco dive switch the ceiling off for the next deco dive", () => { // The same mechanism, one key over - a no-deco dive offers no ceiling // toggle, so a click on one must not write a selection with `ceiling` // stripped. render(); fireEvent.click(screen.getByRole("button", { name: /^Temperature/ })); cleanup(); render( , ); expect( screen.getByRole("button", { name: /^Deco ceiling/ }), ).toHaveAttribute("aria-pressed", "true"); }); it("still carries the choice the diver actually made", () => { // The other half: carrying the untouched keys must not also carry back the // one they switched off. render(); fireEvent.click(screen.getByRole("button", { name: /^Temperature/ })); cleanup(); render(); expect( screen.getByRole("button", { name: /^Temperature/ }), ).toHaveAttribute("aria-pressed", "false"); }); }); describe("DiveProfileChart legend group name", () => { it("names the markers only on a dive that has them", () => { // A group announcing a control it does not contain is the same disagreement // this chart polices everywhere else, one level up in the tree. const { rerender } = render( , ); expect( screen.getByRole("group", { name: "Channels and markers" }), ).toBeInTheDocument(); rerender(); expect(screen.getByRole("group", { name: "Channels" })).toBeInTheDocument(); }); }); describe("DiveProfileChart legend off state", () => { it("draws a switched-off entry in full-strength muted text", () => { // Faded below `text-muted-foreground` it fails AA in both themes - see // DECISIONS.md on the unit toggle's off half, which this matches. render(); const temperature = screen.getByRole("button", { name: /^Temperature/ }); fireEvent.click(temperature); expect(temperature).toHaveAttribute("aria-pressed", "false"); expect(temperature).toHaveClass("text-muted-foreground"); expect(temperature.className).not.toMatch(/text-muted-foreground\//); }); }); describe("DiveProfileChart legend menu", () => { it("groups its entries by the graph each is drawn on", () => { render( , ); const groups = [ ...screen.getByRole("group", { name: "Channels and markers" }).children, ].map((group) => within(group as HTMLElement) .getAllByRole("button") .map((button) => button.textContent?.replace(/ \(.*\)$/, "")), ); expect(groups).toEqual([ ["Depth", "Deco ceiling", "Temperature", "Tank pressure", "Markers"], ["Heart rate"], ["No-deco time"], ["Time to surface"], ["ppO₂"], ["CNS", "Surface gradient factor", "Gradient factor at depth"], ]); }); }); describe("DiveProfileChart with three scales on the depth plot", () => { const stacked = (root: HTMLElement) => [...root.querySelectorAll("text[data-under-right]")].map((label) => ({ text: label.textContent, y: Number(label.getAttribute("y")), })); const temperatureLabels = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='start']")] .filter(outsideThePanel) .filter((label) => !label.hasAttribute("data-under-right")) .map((label) => Number(label.getAttribute("y"))); it("numbers pressure under each of temperature's numbers", () => { const { container } = render(); const pressure = stacked(container); const temperature = temperatureLabels(container); expect(pressure.length).toBeGreaterThan(1); expect(pressure).toHaveLength(temperature.length); // One pair per height, temperature above and pressure below it. pressure.forEach((label, index) => { expect(label.y - temperature[index]).toBeCloseTo(12, 6); }); }); it("gives pressure an edge of its own once temperature is off", () => { const { container } = render(); fireEvent.click( screen.getByRole("button", { name: CHANNEL_BUTTONS.temperature }), ); expect(stacked(container)).toHaveLength(0); expect(rightAxisTicks(container).length).toBeGreaterThan(0); }); }); describe("DiveProfileChart with markers but nothing plottable", () => { // `available` counts the markers now, so the "no samples" guard had to move to // the channels or it would stop firing here - leaving a plot box with a time // axis, a row of ticks and a legend reading only "Markers", with nothing // saying why it is bare. const markersOnly: DiveProfile = { duration: 3_000_000, depth: null, temperature: null, pressures: [], events: [{ time: 600_000, type: "bookmark" }], }; it("says the file recorded no samples, which markers are not", () => { render(); expect( screen.getByText(/recorded no samples to plot/i), ).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); }); describe("DiveProfileChart remembered selection that plots no curve here", () => { // The fallback exists so that "a dive that recorded only what you'd hidden opens // showing what it does have, not blank". Counting the markers as something the // selection plots defeats it: a stored selection whose only surviving key is // `events` leaves the chart with marker ticks, no curve, no axis - and no message, // since the overlay stands down while the markers are up. const depthAndMarkers = longProfile({ events: [{ time: 600_000, type: "bookmark" }], }); it("falls back to the curves this dive does have", () => { window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["temperature", "events"]), ); const { container } = render( , ); expect( container.querySelectorAll(`polyline, ${DEPTH_FILL}`).length, ).toBeGreaterThan(0); expect(leftAxisTicks(container).length).toBeGreaterThan(0); }); it("keeps the markers choice through that fallback", () => { // The half a blanket "ignore the whole selection" fallback would get wrong: // absence of `events` is a real choice under this key, so restoring the curves // must not also switch the markers back on. window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["temperature"]), ); const { container } = render( , ); expect( container.querySelectorAll(`polyline, ${DEPTH_FILL}`).length, ).toBeGreaterThan(0); expect( container.querySelectorAll("g[aria-hidden][opacity] line"), ).toHaveLength(0); }); }); describe("DiveProfileChart deco readouts", () => { // t = 600 s is the third sample of `everyChannel`, so every channel has a real // reading there and none of them is interpolated. const hoverOverTheThirdSample = () => { render(); hoverAt(600 / 3000); }; it("quotes every channel that is switched on, each in its own unit", () => { hoverOverTheThirdSample(); // Minutes rather than the seconds the wire carries, two decimals of bar for // a ppO₂, and a percent sign attached the way a degree is. expect(readoutText()).toMatch(/92 bpm Heart rate/); expect(readoutText()).toMatch(/30 min No-deco time/); expect(readoutText()).toMatch(/0 min Time to surface/); expect(readoutText()).toMatch(/1\.10 bar ppO₂/); expect(readoutText()).toMatch(/2\.4% CNS/); expect(readoutText()).toMatch(/12% Gradient factor at depth/); expect(readoutText()).toMatch(/30% Surface gradient factor/); }); it("groups the readings by the graph each is drawn on", () => { hoverOverTheThirdSample(); // The card is one grid: the elapsed time, then the readings with a rule - // an `aria-hidden` cell - between one graph's and the next. const grid = document.querySelector( "[data-chart-readout]", )?.firstElementChild; const groups = [""]; for (const cell of [...(grid?.children ?? [])].slice(1)) { if (cell.getAttribute("aria-hidden") === "true") groups.push(""); else groups[groups.length - 1] += `${cell.textContent} `; } expect(groups).toHaveLength(6); expect(groups[0]).toMatch(/Depth.*Temperature.*Tank pressure/); expect(groups[1]).toMatch(/Heart rate/); expect(groups[2]).toMatch(/No-deco time/); expect(groups[3]).toMatch(/Time to surface/); expect(groups[4]).toMatch(/ppO₂/); expect(groups[5]).toMatch(/CNS.*Surface gradient factor.*Gradient factor/); }); it("stops quoting a channel the diver switched off", () => { // Hiding a thing hides it everywhere: the curve, the crosshair readout and // the accessible summary all read one visibility. This chart has been talked // out of the "drawn in one view, named in another" disagreement several // times over, and a new channel is a new way back into it. hoverOverTheThirdSample(); expect(readoutText()).toMatch(/No-deco time/); fireEvent.click(screen.getByRole("button", { name: CHANNEL_BUTTONS.ndl })); hoverAt(600 / 3000); expect(readoutText()).not.toMatch(/No-deco time/); expect(readoutText()).toMatch(/Time to surface/); }); it("names the shown panel channels in the accessible summary, and only those", () => { render(); const summary = () => screen.getByRole("img").getAttribute("aria-label") ?? ""; // **Which extreme says something is per quantity.** A maximum NDL is the // device's display cap on almost every recreational dive; the minimum is the // moment the dive came closest to an obligation. expect(summary()).toMatch(/no-decompression time down to 0 minutes/); expect(summary()).toMatch(/time to surface up to 18 minutes/); expect(summary()).toMatch(/oxygen partial pressure up to 1\.32 bar/); expect(summary()).toMatch(/CNS to 12\.4 percent/); expect(summary()).toMatch(/gradient factor at depth to 98 percent/); expect(summary()).toMatch(/surface gradient factor to 84 percent/); fireEvent.click(screen.getByRole("button", { name: CHANNEL_BUTTONS.cns })); expect(summary()).not.toMatch(/CNS to/); expect(summary()).toMatch(/gradient factor at depth to 98 percent/); }); it("numbers each panel row in the unit its curves are in", () => { const { container } = render(); // The top tick of a row carries the unit; the bottom one is a bare number, // because two units on one 46-unit scale is noise. Minutes and bar carry // none - see `axisUnitSuffix`. const topTick = (axis: string) => container.querySelector(`[data-panel="${axis}"] text`)?.textContent ?? ""; expect(topTick("no_deco")).toMatch(/^\d+$/); expect(topTick("time_to_surface")).toMatch(/^\d+$/); expect(topTick("ppo2")).toMatch(/^\d+(\.\d+)?$/); expect(topTick("percent")).toMatch(/%$/); }); it("keeps a gradient factor the device wrote past 100 %", () => { // A Suunto Ocean's `gf99` reaches five figures on a decompression ascent. // The axis stops at 200 % and the curve leaves the row, but what the diver is // *told* is untouched: clamping the reading would be a guess wearing a // plausible number. Which half is bounded is the whole of this fix - see // "DiveProfileChart with a gradient factor past the percent axis's bound" // below for the other half. render( , ); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /gradient factor at depth to 12575 percent/, ); }); it("offers no toggle for a channel this dive's computer never recorded", () => { render(); expect( screen.queryByRole("button", { name: CHANNEL_BUTTONS.ndl }), ).toBeNull(); expect( screen.getByRole("button", { name: CHANNEL_BUTTONS.depth }), ).toBeTruthy(); }); }); describe("DiveProfileChart with a marker carrying no type", () => { // DiveJSON §6.6 makes `type` optional and spells "the device recorded // something and nothing in the vocabulary says what" as an absent type beside // a required label. The API sends a null for exactly this, so a marker with // no type is the ordinary case rather than a broken payload - and it has to // draw, and read, on its own wording. const withTypeless = longProfile({ events: [{ time: 1_500_000, type: null, label: "Violated Deep Stop" }], }); it("draws it and names it by the device's own wording", () => { render(); hoverAt(1500 / 3000); expect(readoutText()).toMatch(/Violated Deep Stop/); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /Violated Deep Stop at/, ); }); it("draws the alarm classes it does know", () => { render( , ); hoverAt(1500 / 3000); // The vocabulary's word, not the vendor's spelling: one value per distinct // meaning is the whole reason the enum is not one entry per vendor string. expect(readoutText()).toMatch(/Deco ceiling broken/); }); }); describe("DiveProfileChart panel scales", () => { // A gradient factor five orders of magnitude over a CNS clock, which is not a // made-up shape: a Suunto Ocean's `gf99` reaches five figures on a // decompression ascent, and the API stores it as the device wrote it. const lopsided = everyChannel({ gradient_factor: { times: [0, 300_000, 600_000], values: [40, 12575, 90] }, cns: { times: [0, 300_000, 600_000], values: [0, 80, 124] }, }); const topTick = (container: HTMLElement, axis: string) => container.querySelector(`[data-panel="${axis}"] text`)?.textContent ?? ""; it("takes a row's scale from the channels on it that are shown", () => { // Unlike the depth axis, which is computed from the ceiling whether or not // the ceiling is plotted: there the hidden channel is bounded by the visible // one, so feeding it in costs nothing and keeps the axis still. A gradient // factor is no such bound, and letting a hidden one set this scale would // draw the CNS clock as a flat line on the baseline. // // The percent axis's own 200 % ceiling does not make this rule redundant, and // these two numbers are why: a hidden gradient factor would still take the // row from a CNS clock's 12.5 % to the full band. const { container } = render(); expect(Number.parseFloat(topTick(container, "percent"))).toBe(200); fireEvent.click( screen.getByRole("button", { name: CHANNEL_BUTTONS.gradient_factor }), ); fireEvent.click( screen.getByRole("button", { name: CHANNEL_BUTTONS.surface_gradient_factor, }), ); expect(Number.parseFloat(topTick(container, "percent"))).toBeLessThan(100); }); it("rounds a fractional scale instead of printing its float noise", () => { // `Math.ceil(1.32 / 0.2) * 0.2` is 1.4000000000000001. `axisTicks` is where // that is already solved, and reading the domain's ends off it rather than // off `domain` directly is what keeps the solution in one place. const { container } = render(); expect(topTick(container, "ppo2")).toMatch(/^\d+(\.\d{1,2})?$/); }); }); describe("DiveProfileChart with a gradient factor past the percent axis's bound", () => { // Dive `019fcee1-2219-76df-9e5c-b20e3473f304` in shape: a Suunto Ocean `gf99` // running to 14 060 % across a third of the ascent, beside the surface gradient // factor that peaks near 170 and is the channel carrying the readable story. // Neither figure is invented: across a 79-dive corpus of exports from this // computer the reading is a device fault rather than a different scale, and the // inequality GF99 <= surface GF - which no decompression model in use can // violate - holds on every sample of the 75 clean dives and fails on two // samples in five of the four broken ones. DECISIONS.md carries the evidence, // under *"The percent axis stops at 200 %"*. // // No CNS, which is not tidiness: the Suunto JSON carries none, so on this dive // the percent row holds the two gradient factors and nothing else. const times = [ 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, 2_400_000, 2_700_000, 3_000_000, ]; const brokenGf = everyChannel({ cns: null, gradient_factor: { times, values: [0, 40, 94, 220, 747, 14060, 6936, 334, 199, 120, 88], }, surface_gradient_factor: { times, values: [0, 20, 60, 110, 150, 170, 166, 140, 100, 60, 30], }, }); // Five of the eleven are above 200 %, which is the same third of the curve the // real dive puts off-scale (70 of its 231 readings). const OVERRUNNING_SAMPLES = 5; const percentRow = (container: HTMLElement) => container.querySelector('[data-panel="percent"]'); // The row's two rules, top first - `bounds` draws them in that order. Every // assertion about leaving the row is measured against these rather than against // a constant, so the row can move without the test lying. const rowRules = (container: HTMLElement) => [...(percentRow(container)?.querySelectorAll("line") ?? [])].map((rule) => Number(rule.getAttribute("y1")), ); // Every y a channel's polylines pass through. Attribute geometry, not layout - // jsdom has no opinion about either, and the SVG's coordinates are the thing // being asserted. const curveYs = (container: HTMLElement, channel: string) => [...container.querySelectorAll(`g.text-${channel} polyline`)].flatMap( (line) => (line.getAttribute("points") ?? "") .split(" ") .filter(Boolean) .map((point) => Number(point.split(",")[1])), ); it("stops the axis at 200 % instead of following the reading to 15 000", () => { const { container } = render(); expect(percentRow(container)?.querySelector("text")?.textContent).toBe( "200%", ); }); it("gives the trustworthy channel the row instead of the baseline", () => { // The damage being repaired: against an axis fitted to 14 060 the surface // gradient factor's whole 170-percent story is 1.1 % of the row's height. const { container } = render(); const [top, bottom] = rowRules(container); const highest = Math.min(...curveYs(container, "surface-gradient-factor")); expect((bottom - highest) / (bottom - top)).toBeGreaterThan(0.8); }); it("draws the overrun leaving the row rather than lying along its top", () => { // Smaller y is higher, so this is the curve drawn *past* the row's top rule, // where the clip then takes it out of the picture. Clamped, every one of // these samples would sit exactly on `top` - and a value flattened against // the top edge reads as a measurement at the top edge. const { container } = render(); const [top] = rowRules(container); const ys = curveYs(container, "gradient-factor"); expect(ys.filter((y) => y < top)).toHaveLength(OVERRUNNING_SAMPLES); // Drawn at the row's own scale on the way out, so 14 060 leaves by much // further than 220 does. A clamp, or a second squashed scale above the // bound, would collapse that difference. expect(Math.min(...ys)).toBeLessThan(top - (rowRules(container)[1] - top)); }); it("clips each panel row, so what leaves it leaves the picture", () => { const { container } = render(); const reference = container .querySelector("g.text-gradient-factor") ?.getAttribute("clip-path") ?? ""; // Resolvable as a URL fragment, which is what `clipPrefix`'s strip is for - // React spells `useId` with characters that are legal in an `id` and not in // the `url(#...)` that has to find it. expect(reference).toMatch(/^url\(#[A-Za-z0-9_-]+\)$/); const clip = container.querySelector( `clipPath[id="${reference.slice(5, -1)}"] rect`, ); // Half a stroke outside each rule, so a reading on the rule is not drawn // half as thick as the same line anywhere else. const [top, bottom] = rowRules(container); expect(Number(clip?.getAttribute("y"))).toBe(top - 0.75); expect(Number(clip?.getAttribute("height"))).toBe(bottom - top + 1.5); // And not applied to the depth plot, whose domain covers its own curve by // construction and whose readout dots sit on its edges - a clip there would // halve them. expect( container.querySelector(DEPTH_FILL)?.closest("[clip-path]"), ).toBeNull(); }); it("still quotes what the device wrote, with no dot to hang it on", () => { // Property one of the ruling: the axis is bounded and the reading is not. A // dot at the sample's own y would land over the depth plot, and one pulled // back to the row's top edge would claim the curve is up there. const { container } = render(); hoverAt(1500 / 3000); expect(readoutText()).toMatch(/14060% Gradient factor at depth/); expect( container.querySelectorAll("circle.text-gradient-factor"), ).toHaveLength(0); expect( container.querySelectorAll("circle.text-surface-gradient-factor").length, ).toBeGreaterThan(0); }); it("leaves a dive whose readings fit exactly as it was", () => { // The 75 clean dives of the corpus. `everyChannel`'s gradient factor peaks at // 98 and its surface one at 84, so the bound never comes into it: the axis is // fitted, and no curve leaves the row. const { container } = render(); const [top] = rowRules(container); expect(percentRow(container)?.querySelector("text")?.textContent).not.toBe( "200%", ); expect(curveYs(container, "gradient-factor").every((y) => y >= top)).toBe( true, ); }); }); describe("DiveProfileChart over a gap in one channel", () => { // A transmitter that went quiet after ten minutes, under a depth series that // runs the whole dive. const dropout = longProfile({ pressures: [ { gas_number: 1, times: [0, 300_000, 600_000], values: [2000, 1900, 1800], }, ], }); it("keeps the channel's row, with a dash for the reading", () => { const { container } = render(); hoverAt(0.8); expect(readoutText()).toMatch(/\d m Depth/); expect(readoutText()).toMatch(/— Tank pressure/); expect( container.querySelectorAll("circle[class*='text-pressure']"), ).toHaveLength(0); }); }); describe("DiveProfileChart heart rate", () => { // A reading a second, as a wrist sensor writes them, with the strap losing // contact for 40 s a minute in. The reading at 60 s is the one to quote. const second = (index: number) => index * 1_000; const wrist = profile({ heart_rate: { times: [ ...Array.from({ length: 61 }, (_, index) => second(index)), ...Array.from({ length: 201 }, (_, index) => second(100 + index)), ], values: [ ...Array.from({ length: 60 }, () => 80), 91, ...Array.from({ length: 201 }, () => 85), ], }, }); it("quotes the reading 2 s back, and a dash inside the dropout", () => { render(); hoverAt(62 / 300); expect(readoutText()).toMatch(/91 bpm Heart rate/); hoverAt(80 / 300); expect(readoutText()).toMatch(/— Heart rate/); }); it("labels its row's top tick with the readings' ceiling", () => { const { container } = render(); // 72 to 107 bpm, so the row tops out at 110 and nothing above the readings // sets its scale. expect( container.querySelector('[data-panel="heart_rate"] text')?.textContent, ).toBe("110"); // First of the rows under the depth plot: the one measurement among them. expect(panelRows(container)[0]).toBe("heart_rate"); }); it("takes its row away when switched off, and moves no other scale", () => { const { container } = render(); const scales = () => Object.fromEntries( [...container.querySelectorAll("[data-panel]")].map((row) => [ row.getAttribute("data-panel"), [...row.querySelectorAll("text")].map((tick) => tick.textContent), ]), ); const before = scales(); const edges = [leftAxisTicks(container), rightAxisTicks(container)]; fireEvent.click( screen.getByRole("button", { name: CHANNEL_BUTTONS.heart_rate }), ); const { heart_rate: removed, ...rest } = before; expect(removed).toBeDefined(); expect(scales()).toEqual(rest); expect([leftAxisTicks(container), rightAxisTicks(container)]).toEqual( edges, ); }); it.each(["metric", "imperial"] as const)( "lists it in the legend with its unit, in %s", (units) => { auth.units = units; render(); expect( screen.getByRole("button", { name: "Heart rate (bpm)" }), ).toBeTruthy(); }, ); it("is on by default, like every other channel", () => { render(); expect( screen .getByRole("button", { name: CHANNEL_BUTTONS.heart_rate }) .getAttribute("aria-pressed"), ).toBe("true"); }); it("states its range in the accessible summary", () => { render(); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /heart rate 80 to 91 beats per minute/, ); }); }); describe("DiveProfileChart in full screen", () => { const openFullscreen = () => { render(); fireEvent.click(screen.getByRole("button", { name: "Full screen" })); return screen.getByRole("dialog", { name: "Dive Profile" }); }; it("reads out into a panel beside the plot rather than over it", () => { const dialog = openFullscreen(); // Nothing under the crosshair yet: every row is there, with a dash. expect(dialog.textContent).toMatch(/— elapsed/); expect(dialog.textContent).toMatch(/— Depth/); hoverAt(0.5); expect(dialog.textContent).toMatch(/25:00 elapsed/); expect(dialog.textContent).toMatch(/30\.0 m Depth/); expect(screen.queryByRole("presentation")).toBeNull(); }); it("hands focus back to the button that opened it on the way out", async () => { const dialog = openFullscreen(); fireEvent.click( within(dialog).getByRole("button", { name: "Exit full screen" }), ); expect(screen.queryByRole("dialog")).toBeNull(); // Base UI moves focus on the tick after the dialog unmounts. await waitFor(() => expect(document.activeElement).toBe( screen.getByRole("button", { name: "Full screen" }), ), ); }); }); describe("DiveProfileChart scaled with the dive's other recordings", () => { // Two computers on one dive, a couple of degrees and a few meters apart, as a // wrist unit and a hose-mounted one often are. const perdix = everyChannel({ temperature: { times: [0, 1_500_000, 3_000_000], values: [2600, 2550, 2580], }, }); const ocean = everyChannel({ depth: { times: [0, 1_500_000, 3_000_000], values: [0, 4200, 0], }, temperature: { times: [0, 1_500_000, 3_000_000], values: [2800, 2750, 2780], }, heart_rate: { times: [0, 1_500_000, 3_000_000], values: [70, 160, 80], }, }); const axes = (shown: DiveProfile, other: DiveProfile) => { const { container, unmount } = render( , ); const read = { left: leftAxisTicks(container), right: rightAxisTicks(container), heartRate: container.querySelector('[data-panel="heart_rate"] text') ?.textContent ?? "", }; unmount(); return read; }; beforeEach(() => { window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["depth", "temperature", "heart_rate"]), ); }); it("numbers every axis the same whichever computer is shown", () => { const fromPerdix = axes(perdix, ocean); const fromOcean = axes(ocean, perdix); expect(fromPerdix).toEqual(fromOcean); expect(fromPerdix.heartRate).not.toBe(""); }); it("spans both computers' readings rather than the shown one's", () => { const { right } = axes(perdix, ocean); const degrees = right.map((tick) => Number.parseFloat(tick ?? "")); expect(Math.min(...degrees)).toBeLessThanOrEqual(25.5); expect(Math.max(...degrees)).toBeGreaterThanOrEqual(28); }); }); describe("DiveProfileChart on the dive's shared time axis", () => { // Two computers on one 50-minute dive, the second switched on ten minutes // after the first: the API places the first at 0 and the second at 600 000, // and the axis runs to the later of their ends. // Sampled every 30 s, so the crosshair's tolerance is a minute and a half // rather than the quarter hour `longProfile`'s cadence would give it. const AXIS_END = 3_600_000; const times = Array.from({ length: 101 }, (_, index) => index * 30_000); const dense = { times, values: times.map((time) => (time === 0 || time === 3_000_000 ? 0 : 2000)), }; const first = longProfile({ depth: dense }); const later = longProfile({ depth: dense, events: [ { time: 1_000, type: "gas_switch", gas_number: 0 }, { time: 10_000, type: "bookmark" }, ], }); const firstAxis = { start: 0, end: AXIS_END }; const laterAxis = { start: 600_000, end: AXIS_END }; const elapsedLabels = (root: HTMLElement) => [...root.querySelectorAll("text[text-anchor='middle']")].map( (label) => label.textContent, ); beforeEach(() => { window.localStorage.setItem( DIVE_PROFILE_SERIES_KEY, JSON.stringify(["depth", "events"]), ); }); it("draws the later recording from its place, not from the left edge", () => { const { container } = render( , ); const plot = plotEdges(container); expect(firstFillX(container, DEPTH_FILL)).toBeCloseTo( plot.left + (600_000 / AXIS_END) * plot.width, 6, ); }); it("labels the elapsed axis the same whichever recording is shown", () => { const fromFirst = render( , ); const firstLabels = elapsedLabels(fromFirst.container); fromFirst.unmount(); const fromLater = render( , ); expect(elapsedLabels(fromLater.container)).toEqual(firstLabels); expect(firstLabels[firstLabels.length - 1]).toBe("60:00"); }); it("draws and speaks a marker at its own time plus the recording's place", () => { const { container } = render( , ); const plot = plotEdges(container); const ticks = [ ...container.querySelectorAll("g[aria-hidden][opacity] line"), ].map((tick) => Number(tick.getAttribute("x1"))); expect(ticks[1]).toBeCloseTo( plot.left + (610_000 / AXIS_END) * plot.width, 6, ); expect(screen.getByRole("img").getAttribute("aria-label")).toContain( "Bookmark at 10min", ); }); it("still calls the recording's opening gas the dive's start", () => { render(); expect(screen.getByRole("img").getAttribute("aria-label")).toContain( "Dive started at 10min", ); }); it("speaks the span the recording itself covers", () => { render(); expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( /^Dive profile over 50min,/, ); }); it("quotes nothing from before the recording began", () => { render(); // Five minutes in, five before the later computer's first sample - and // within its tolerance of that sample, the reading is quoted as ever. hoverAt(300_000 / AXIS_END); expect(readoutText()).toMatch(/^5:00 elapsed— Depth/); hoverAt(570_000 / AXIS_END); expect(readoutText()).toMatch(/^9:30 elapsed0\.0 m Depth/); }); it("draws a recording with no place on its own axis", () => { const { container } = render( , ); const labels = elapsedLabels(container); expect(labels[labels.length - 1]).toBe("50:00"); expect(firstFillX(container, DEPTH_FILL)).toBeCloseTo( plotEdges(container).left, 6, ); }); });