src/components/dives/dive-profile-chart.render.test.tsx
Rama main · réplica de lectura del repositorio original.
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<DiveProfileChartProps, "menuContainer">) {
const [slot, setSlot] = useState<HTMLElement | null>(null);
return (
<>
<div ref={setSlot} />
<Chart {...props} menuContainer={slot} />
</>
);
}
// 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> = {}): 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> = {}): 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> = {}): 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<SVGRectElement>(
'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(<DiveProfileChart profile={suuntoOcean} />);
// 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(<DiveProfileChart profile={longProfile()} />);
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(<DiveProfileChart profile={longProfile()} />);
hoverAt(0.5);
expect(readoutText()).toMatch(/^25:00 elapsed/);
});
it("reads the span aloud in minutes", () => {
render(<DiveProfileChart profile={longProfile()} />);
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(<DiveProfileChart profile={imperialDive} />);
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(<DiveProfileChart profile={imperialDive} />);
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(<DiveProfileChart profile={imperialDive} />);
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(<DiveProfileChart profile={dive(values)} />);
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(
<DiveProfileChart profile={dive([2711, 2705, 2690, 2669, 2672, 2680])} />,
);
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(
<DiveProfileChart
profile={profile({ events: [rogue("NDL Violation")] })}
/>,
),
).not.toThrow();
expect(screen.getByRole("img")).toBeInTheDocument();
});
it("degrades to the device's own wording in the accessible summary", () => {
render(
<DiveProfileChart
profile={profile({ events: [rogue("NDL Violation")] })}
/>,
);
// 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(<DiveProfileChart profile={profile({ events: [rogue()] })} />);
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(<DiveProfileChart profile={withCeiling} />);
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(<DiveProfileChart profile={withCeiling} />);
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(<DiveProfileChart profile={withCeiling} />);
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(<DiveProfileChart profile={profile()} />);
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(<DiveProfileChart profile={shortObligation} />);
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(<DiveProfileChart profile={shortObligation} />);
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(
<DiveProfileChart
profile={longProfile({ events: [{ time: 900_000, type: "bookmark" }] })}
/>,
);
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(<DiveProfileChart profile={twoMoments} />);
// 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(
<DiveProfileChart
profile={longProfile({
ceiling: { times: [1_400_000, 1_410_000], values: [300, 300] },
})}
/>,
);
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(<DiveProfileChart profile={twoMoments} />);
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(<DiveProfileChart profile={twoMoments} />);
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(<DiveProfileChart profile={twoMoments} />);
// 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(<DiveProfileChart profile={twoMoments} />);
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(<DiveProfileChart profile={partlyDrawn} />);
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(<DiveProfileChart profile={partlyDrawn} />);
// 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(<DiveProfileChart profile={partlyDrawn} />);
hoverAt(610 / 3000);
expect(readoutText()).toMatch(/3\.0 m Deco ceiling/);
});
it("quotes nothing at the undrawn sample", () => {
render(<DiveProfileChart profile={partlyDrawn} />);
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(
<DiveProfileChart
profile={{
duration: 3_000_000,
depth: { times: [100_000, 400_000], values: [1000, 2000] },
temperature: null,
pressures: [],
events: [],
}}
/>,
);
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(
<DiveProfileChart
profile={longProfile({
temperature: { times: [100_000, 160_000], values: [2200, 2190] },
})}
/>,
);
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(<DiveProfileChart profile={sparseCeiling} />);
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(<DiveProfileChart profile={sparseCeiling} />);
const withoutCeiling = render(<DiveProfileChart profile={longProfile()} />);
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(<DiveProfileChart profile={withDropout} />);
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(<DiveProfileChart profile={withDropout} />);
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(<DiveProfileChart profile={longProfile()} />);
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(
<DiveProfileChart profile={everyChannel()} />,
);
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(
<DiveProfileChart profile={everyChannel()} />,
);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={everyChannel()} />).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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={late} />);
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(<DiveProfileChart profile={late} />);
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(<DiveProfileChart profile={dive} />);
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(<DiveProfileChart profile={dive} />);
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(
<DiveProfileChart profile={profile} />,
);
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(<DiveProfileChart profile={dive} />);
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(<DiveProfileChart profile={dive} />);
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(<DiveProfileChart profile={dive} />);
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(<DiveProfileChart profile={recorded(null)} />);
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(<DiveProfileChart profile={twoTanks} mixtures={mixtures} />);
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(<DiveProfileChart profile={twoTanks} mixtures={mixtures} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={longProfile()} />);
expect(
screen.queryByRole("button", { name: /^Markers/ }),
).not.toBeInTheDocument();
});
it("takes the markers off the plot when switched off", () => {
const { container } = render(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={noMarkers} />);
fireEvent.click(screen.getByRole("button", { name: /^Temperature/ }));
cleanup();
render(<DiveProfileChart profile={withMarkers} />);
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(<DiveProfileChart profile={noMarkers} />);
fireEvent.click(screen.getByRole("button", { name: /^Temperature/ }));
cleanup();
render(
<DiveProfileChart
profile={longProfile({
ceiling: {
times: [1_200_000, 1_210_000, 1_220_000],
values: [300, 300, 320],
},
})}
/>,
);
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(<DiveProfileChart profile={noMarkers} />);
fireEvent.click(screen.getByRole("button", { name: /^Temperature/ }));
cleanup();
render(<DiveProfileChart profile={withMarkers} />);
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(
<DiveProfileChart
profile={longProfile({ events: [{ time: 600_000, type: "bookmark" }] })}
/>,
);
expect(
screen.getByRole("group", { name: "Channels and markers" }),
).toBeInTheDocument();
rerender(<DiveProfileChart profile={longProfile()} />);
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(<DiveProfileChart profile={everyChannel()} />);
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(
<DiveProfileChart
profile={everyChannel({
events: [{ time: 600_000, type: "bookmark" }],
})}
/>,
);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={markersOnly} />);
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(
<DiveProfileChart profile={depthAndMarkers} />,
);
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(
<DiveProfileChart profile={depthAndMarkers} />,
);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={everyChannel()} />);
// 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(
<DiveProfileChart
profile={everyChannel({
gradient_factor: {
times: [0, 300_000, 600_000],
values: [40, 12575, 90],
},
})}
/>,
);
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(<DiveProfileChart profile={longProfile()} />);
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(<DiveProfileChart profile={withTypeless} />);
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(
<DiveProfileChart
profile={longProfile({
events: [
{
time: 1_500_000,
type: "ceiling_violation",
label: "Ceiling Broken",
},
],
})}
/>,
);
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(<DiveProfileChart profile={lopsided} />);
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(<DiveProfileChart profile={lopsided} />);
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(<DiveProfileChart profile={brokenGf} />);
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(<DiveProfileChart profile={brokenGf} />);
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(<DiveProfileChart profile={brokenGf} />);
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(<DiveProfileChart profile={brokenGf} />);
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(<DiveProfileChart profile={brokenGf} />);
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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={dropout} />);
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(<DiveProfileChart profile={wrist} />);
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(<DiveProfileChart profile={everyChannel()} />);
// 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(<DiveProfileChart profile={everyChannel()} />);
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(<DiveProfileChart profile={wrist} />);
expect(
screen.getByRole("button", { name: "Heart rate (bpm)" }),
).toBeTruthy();
},
);
it("is on by default, like every other channel", () => {
render(<DiveProfileChart profile={wrist} />);
expect(
screen
.getByRole("button", { name: CHANNEL_BUTTONS.heart_rate })
.getAttribute("aria-pressed"),
).toBe("true");
});
it("states its range in the accessible summary", () => {
render(<DiveProfileChart profile={wrist} />);
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(
/heart rate 80 to 91 beats per minute/,
);
});
});
describe("DiveProfileChart in full screen", () => {
const openFullscreen = () => {
render(<DiveProfileChart profile={longProfile()} />);
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(
<DiveProfileChart profile={shown} scaleWith={[other]} />,
);
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(
<DiveProfileChart profile={later} axis={laterAxis} />,
);
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(
<DiveProfileChart profile={first} axis={firstAxis} />,
);
const firstLabels = elapsedLabels(fromFirst.container);
fromFirst.unmount();
const fromLater = render(
<DiveProfileChart profile={later} axis={laterAxis} />,
);
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(
<DiveProfileChart profile={later} axis={laterAxis} />,
);
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(<DiveProfileChart profile={later} axis={laterAxis} />);
expect(screen.getByRole("img").getAttribute("aria-label")).toContain(
"Dive started at 10min",
);
});
it("speaks the span the recording itself covers", () => {
render(<DiveProfileChart profile={later} axis={laterAxis} />);
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(
/^Dive profile over 50min,/,
);
});
it("quotes nothing from before the recording began", () => {
render(<DiveProfileChart profile={later} axis={laterAxis} />);
// 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(
<DiveProfileChart profile={later} axis={null} />,
);
const labels = elapsedLabels(container);
expect(labels[labels.length - 1]).toBe("50:00");
expect(firstFillX(container, DEPTH_FILL)).toBeCloseTo(
plotEdges(container).left,
6,
);
});
});