src/components/dives/mixture-fields.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormProvider, useForm } from "react-hook-form";
import { MixtureFields, useMixtureFieldArray } from "./mixture-fields";
import type { DiveFormValues } from "./dive-form-fields";
import type { DiveFormFieldKey } from "@/lib/dive-form-fields";
import type { UnitSystem } from "@/lib/units";
import {
parseEntryUnits,
readStoredEntryUnits,
writeEntryUnits,
} from "@/lib/entry-units";
import { memoryStorage, useStorage } from "@/test/memory-storage";
import { gearAPI, type GearItemInfo } from "@/lib/api/gear";
// The pressure boxes and their labels read the diver's units, so these renders 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 tank's cylinder picker asks the gear lookup, and the volume box the diver's
// recent volumes, only once opened; the picker tests below open both.
vi.mock("@/lib/api/gear", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/gear")>();
return {
...actual,
gearAPI: { ...actual.gearAPI, lookupGearItems: vi.fn() },
};
});
vi.mock("@/lib/api/dives", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/dives")>();
return {
...actual,
divesAPI: {
...actual.divesAPI,
getRecentVolumes: vi.fn(async () => []),
},
};
});
// The warning sentence is unit-tested in `lib/dive-mixtures.test.ts`. What only a
// render reaches is the announcement wiring: that the `role="status"` region exists
// before the sentence does - a region that mounts together with its text is typically
// not announced at all - and that it settles rather than tracking every keystroke,
// since the sentence quotes the depth.
function Harness({
mixtures,
maxDepth,
hidden = [],
}: {
mixtures: DiveFormValues["mixtures"];
maxDepth: number | null;
// The per-cylinder keys this form is *not* showing, as the Fields dialog would
// have it. Given as the hidden set rather than as a predicate so a test reads the
// way the stored preference does.
hidden?: DiveFormFieldKey[];
}) {
const form = useForm<DiveFormValues>({
defaultValues: { mixtures, max_depth: maxDepth } as DiveFormValues,
});
const fieldArray = useMixtureFieldArray(form.control);
return (
<FormProvider {...form}>
<MixtureFields
control={form.control}
fieldArray={fieldArray}
isVisible={(key) => !hidden.includes(key)}
/>
</FormProvider>
);
}
const EAN54 = { volume: 11.1, oxygen: 54, helium: 0 };
afterEach(() => {
vi.useRealTimers();
});
describe("MixtureFields announcements", () => {
it("mounts the status region before there is anything to announce", () => {
render(<Harness mixtures={[EAN54]} maxDepth={10} />);
const status = screen.getByRole("status");
expect(status).toBeInTheDocument();
expect(status).toHaveTextContent("");
});
it("announces the warning once it settles", () => {
vi.useFakeTimers();
render(<Harness mixtures={[EAN54]} maxDepth={45} />);
// The visible copy is immediate; the announced copy waits.
expect(screen.getByRole("status")).toHaveTextContent("");
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByRole("status")).toHaveTextContent(
/past this mix's 19.62 m limit/i,
);
});
it("keeps the visible copy out of the accessibility tree", () => {
// Otherwise the sentence is read twice: once from the copy that tracks every
// keystroke, once from the settled region.
render(<Harness mixtures={[EAN54]} maxDepth={45} />);
const visible = screen.getByText(/past this mix's 19.62 m limit/i, {
ignore: '[role="status"]',
});
expect(visible.closest("[aria-hidden]")).not.toBeNull();
});
});
// Both dive forms can hold zero cylinders - a state the API supports outright and the
// detail card calls the common case for a hand-logged dive - so the card has to say
// something over an empty list, and the last row has to be removable to get there. A
// gate of `index > 0` on the remove button made "no gas recorded" unreachable from
// either form.
describe("MixtureFields with no cylinders", () => {
it("says the dive records none, rather than showing a bare heading", () => {
render(<Harness mixtures={[]} maxDepth={30} />);
expect(
screen.getByText(/no tanks recorded for this dive/i),
).toBeInTheDocument();
expect(screen.queryByText(/^tank 1$/i)).not.toBeInTheDocument();
});
it("lets the last cylinder be removed", () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
fireEvent.click(screen.getByRole("button", { name: /remove tank 1/i }));
expect(screen.queryByText(/^tank 1$/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no tanks recorded for this dive/i),
).toBeInTheDocument();
});
it("names each remove button after the tank it removes", () => {
// The button is an icon and nothing else, so without a label a screen reader
// reads "button" - once per tank, identically.
render(<Harness mixtures={[EAN54, EAN54]} maxDepth={30} />);
expect(
screen.getByRole("button", { name: /remove tank 1/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /remove tank 2/i }),
).toBeInTheDocument();
});
});
// The add button sits under the tank cards rather than in the section header, so the
// control and the card it appends are adjacent and in reading order. DOM order is the
// half of that jsdom can actually check - the layout half is measured in a browser and
// recorded in DECISIONS.md - and it is the half that a later edit to this component
// would silently undo.
describe("MixtureFields add button placement", () => {
it("follows the last tank rather than preceding the first", () => {
render(<Harness mixtures={[EAN54, EAN54]} maxDepth={30} />);
const add = screen.getByRole("button", { name: /add tank/i });
const lastRemove = screen.getByRole("button", { name: /remove tank 2/i });
expect(
lastRemove.compareDocumentPosition(add) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("follows the empty-state line when there are no tanks", () => {
render(<Harness mixtures={[]} maxDepth={30} />);
const add = screen.getByRole("button", { name: /add tank/i });
const empty = screen.getByText(/no tanks recorded for this dive/i);
expect(
empty.compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
// The options one `<select>` offers, in the order it offers them.
function optionsOf(select: HTMLElement): string[] {
return [...select.querySelectorAll("option")].map((option) => option.value);
}
// Both pickers exist to carry a value an import put there without the diver having
// chosen it, so what a render has to show is that "not recorded" survives a round trip
// through the form as absence rather than as a number or a string - and, for the ppO₂
// one, that a recorded value the list doesn't offer survives it at all.
describe("MixtureFields ppO₂ limit picker", () => {
it("names the fallback instead of pre-selecting it", () => {
// Seeding 1.4 would make every cylinder claim a limit the diver never chose, and
// the MOD above would then be attributed to a decision nobody made.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
const select = screen.getByLabelText(/ppO₂ limit/i);
expect(select).toHaveValue("");
expect(
screen.getByRole("option", { name: /not recorded \(1\.4 default\)/i }),
).toBeInTheDocument();
});
it("shows a recorded limit, and works the hint's MOD out at it", () => {
render(<Harness mixtures={[{ ...EAN54, po2_limit: 1.6 }]} maxDepth={20} />);
expect(screen.getByLabelText(/ppO₂ limit/i)).toHaveValue("1.6");
// EAN54 at 1.6 is 19.62 m; at the 1.4 default it would be 15.92 m. The hint and
// the box have to agree, which is the whole reason `ppO2Limit` is one function.
expect(screen.getByText(/MOD 19\.62 m @ ppO₂ 1\.6/)).toBeInTheDocument();
});
it("offers only limits inside the band the API enforces", () => {
// `ck_dive_mixture_po2_limit_range` is 0.4-2.0, and a value outside it turns a
// save into a 422 the diver can't read. A picker can't produce one at all,
// which is most of the reason it replaced the number box.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
const offered = optionsOf(screen.getByLabelText(/ppO₂ limit/i));
expect(offered).toEqual([
"",
"1.0",
"1.1",
"1.2",
"1.3",
"1.4",
"1.5",
"1.6",
]);
expect(
offered
.filter((option) => option !== "")
.every((option) => Number(option) >= 0.4 && Number(option) <= 2.0),
).toBe(true);
});
it("keeps a recorded limit it doesn't offer, in its place in the list", () => {
// A `<select>` whose value matches no option renders blank while the form goes
// on holding the value - so the box would read "not recorded" over a cylinder
// that records 1.45, and the diver would find out by saving.
render(
<Harness mixtures={[{ ...EAN54, po2_limit: 1.45 }]} maxDepth={30} />,
);
const select = screen.getByLabelText(/ppO₂ limit/i);
expect(select).toHaveValue("1.45");
expect(optionsOf(select)).toEqual([
"",
"1.0",
"1.1",
"1.2",
"1.3",
"1.4",
"1.45",
"1.5",
"1.6",
]);
});
it("selects a limit the diver picks", () => {
render(<Harness mixtures={[EAN54]} maxDepth={20} />);
const select = screen.getByLabelText(/ppO₂ limit/i);
fireEvent.change(select, { target: { value: "1.6" } });
expect(select).toHaveValue("1.6");
// And the number reaches the form as a number, not as the option's string -
// `normalizeMixtures` passes `po2_limit` straight to the API.
expect(screen.getByText(/MOD 19\.62 m @ ppO₂ 1\.6/)).toBeInTheDocument();
});
});
describe("MixtureFields role input", () => {
it("defaults to an explicit 'not recorded', which most cylinders are", () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByLabelText(/^role$/i)).toHaveValue("");
expect(
// Scoped to this `<select>`, not swept off the card. Anchoring alone used
// to be enough - the ppO₂ picker's own empty option reads "Not recorded
// (1.4 default)", so only a loose match found two - but Usage beside it now
// spells its empty option exactly the way Role does, and `getByRole` throws
// on the pair.
within(screen.getByLabelText(/^role$/i)).getByRole("option", {
name: /^not recorded$/i,
}),
).toBeInTheDocument();
});
it("offers exactly the roles the API accepts", () => {
// `GAS_ROLES` mirrors the API's `GasRole` enum, which is `extra="forbid"` on the
// way in - an option this list invented would be rejected on save.
//
// Scoped to this `<select>` rather than swept off the whole card, which is what
// it did while it was the only one: the ppO₂ picker beside it is a second list
// of options and would fail this by existing.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(optionsOf(screen.getByLabelText(/^role$/i))).toEqual([
"",
"bottom",
"deco",
"diluent",
"oxygen",
]);
});
it("shows a role an import recorded", () => {
render(<Harness mixtures={[{ ...EAN54, role: "deco" }]} maxDepth={30} />);
expect(screen.getByLabelText(/^role$/i)).toHaveValue("deco");
});
it("lets an imported role actually be cleared", () => {
// "Not recorded" is a real option whose value is "". Writing `undefined` on
// clear made react-hook-form re-display the default, so choosing "Not recorded" snapped back to Deco - the
// trap `diveMixtureSchema` documents and the numeric fields already dodge with "".
render(<Harness mixtures={[{ ...EAN54, role: "deco" }]} maxDepth={30} />);
const select = screen.getByLabelText(/^role$/i);
fireEvent.change(select, { target: { value: "" } });
expect(select).toHaveValue("");
});
it("defaults usage to an explicit 'not recorded', which every import is", () => {
// No format this app parses carries the flag, so unlike Role this one is
// *always* unset until the diver answers it.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByLabelText(/^usage$/i)).toHaveValue("");
});
it("offers exactly the usages the API accepts", () => {
// `TANK_USAGE` mirrors the API's `TankUsage` enum, which is `extra="forbid"`
// on the way in - an option this list invented would be rejected on save.
// Scoped to this `<select>` for the same reason the role one is.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(optionsOf(screen.getByLabelText(/^usage$/i))).toEqual([
"",
"parallel",
"staged",
]);
});
it("spells out what each usage means, which the one-word labels do not", () => {
// The flag changes what the API computes, so choosing it by guessing at the
// word is the outcome worth spending option width to prevent.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
const select = screen.getByLabelText(/^usage$/i);
expect(
within(select).getByRole("option", { name: /sidemount \/ independent/i }),
).toBeInTheDocument();
expect(
within(select).getByRole("option", { name: /own depth/i }),
).toBeInTheDocument();
});
it("lets a chosen usage be cleared back to not recorded", () => {
// Same react-hook-form trap as Role: writing `undefined` on clear re-displays
// the field's default, so "Not recorded" would snap back to Parallel.
render(
<Harness mixtures={[{ ...EAN54, usage: "parallel" }]} maxDepth={30} />,
);
const select = screen.getByLabelText(/^usage$/i);
expect(select).toHaveValue("parallel");
fireEvent.change(select, { target: { value: "" } });
expect(select).toHaveValue("");
});
it("lets each cylinder answer usage for itself", () => {
// Per row, not once for the dive: a parallel pair plus a staged bottle is a
// real set, and it is the one the API refuses by design - which it can only
// do if the form can express it.
render(
<Harness
mixtures={[
{ ...EAN54, usage: "parallel" },
{ ...EAN54, usage: "staged" },
]}
maxDepth={30}
/>,
);
const selects = screen.getAllByLabelText(/^usage$/i);
expect(selects).toHaveLength(2);
expect(selects[0]).toHaveValue("parallel");
expect(selects[1]).toHaveValue("staged");
});
it("lets a recorded ppO₂ limit be cleared back to the default", () => {
render(<Harness mixtures={[{ ...EAN54, po2_limit: 1.6 }]} maxDepth={30} />);
const select = screen.getByLabelText(/ppO₂ limit/i);
fireEvent.change(select, { target: { value: "" } });
expect(select).toHaveValue("");
// And the hint falls back to naming the working default, rather than keeping the
// limit the box no longer holds.
expect(screen.getByText(/@ ppO₂ 1\.4/)).toBeInTheDocument();
});
});
// A cylinder may record a mix with no vessel and a vessel with no mix, so all three
// of these boxes have to be clearable and have to *stay* clear. The trap is the one
// `role` documents above: `undefined` is what react-hook-form reads as "show the
// default", so a field that clears to it fills itself straight back in.
describe("MixtureFields blank gas fields", () => {
it("lets an imported cylinder size be cleared and stay cleared", () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
const volume = screen.getByLabelText("Volume (L)");
fireEvent.change(volume, { target: { value: "" } });
expect(volume).toHaveValue("");
});
it("lets the O₂ and He boxes be cleared and stay cleared", () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
const oxygen = screen.getByLabelText("O₂ (%)");
const helium = screen.getByLabelText("He (%)");
fireEvent.change(oxygen, { target: { value: "" } });
fireEvent.change(helium, { target: { value: "" } });
expect(oxygen).toHaveValue(null);
expect(helium).toHaveValue(null);
});
it("stops naming the gas once the O₂ box is empty", () => {
// Not "EAN0" and not the EAN54 that was there: an unrecorded fraction is not a
// gas, so the hint under the boxes has nothing to say and says nothing.
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByText(/EAN54/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("O₂ (%)"), {
target: { value: "" },
});
expect(screen.queryByText(/EAN/)).not.toBeInTheDocument();
expect(screen.queryByText(/MOD/)).not.toBeInTheDocument();
});
it("renders an empty volume box for a cylinder that records no size", () => {
render(<Harness mixtures={[{ ...EAN54, volume: "" }]} maxDepth={30} />);
expect(screen.getByLabelText("Volume (L)")).toHaveValue("");
// The gas it does record is still named: the two facts are independent, which is
// the whole reason the column became nullable on its own.
expect(screen.getByText(/EAN54/)).toBeInTheDocument();
});
});
// The per-dimension entry switch. Every test here installs its own storage - under
// this runner `window.localStorage` reads back as `undefined` and the override
// module's try/catch turns that into "no override", so a suite written without it
// passes with the whole feature deleted.
describe("MixtureFields entry units", () => {
beforeEach(() => {
useStorage(memoryStorage());
});
// Every pressure box carries one; any of them flips them all.
const pressureToggle = () =>
screen.getAllByLabelText("bar | psi — switch pressure entry to psi")[0];
it("enters in the account's units until the toggle is pressed", () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByLabelText("Start pressure (bar)")).toBeInTheDocument();
});
it("relabels and reformats both pressures when flipped to psi", async () => {
render(
<Harness
mixtures={[{ ...EAN54, start_pressure: 206.84, end_pressure: 51.71 }]}
maxDepth={30}
/>,
);
await userEvent.click(pressureToggle());
expect(screen.getByLabelText("Start pressure (psi)")).toHaveValue(3000);
expect(screen.getByLabelText("End pressure (psi)")).toHaveValue(750);
});
it("commits the bar behind a pressure typed in psi", async () => {
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
await userEvent.click(pressureToggle());
await userEvent.type(screen.getByLabelText("Start pressure (psi)"), "3000");
// Flipping back is what shows what form state is actually holding: metric,
// whatever the box was labelled while the number went in. That invariant is
// what keeps the wire shape and every Zod rule untouched by this feature -
// had 3000 landed in state, this box would read 3000.
await userEvent.click(
screen.getAllByLabelText("bar | psi — switch pressure entry to bar")[0],
);
expect(screen.getByLabelText("Start pressure (bar)")).toHaveValue(206.84);
});
it("puts a toggle on every pressure box, and flips every tank from any one", async () => {
render(
<Harness
mixtures={[
{ ...EAN54, start_pressure: 206.84 },
{ ...EAN54, start_pressure: 206.84 },
]}
maxDepth={30}
/>,
);
expect(
screen.getAllByLabelText("bar | psi — switch pressure entry to psi"),
).toHaveLength(4);
await userEvent.click(pressureToggle());
expect(screen.getAllByLabelText("Start pressure (psi)")).toHaveLength(2);
expect(screen.getAllByLabelText("End pressure (psi)")).toHaveLength(2);
});
it("shows no pressure toggle over an empty cylinder list", () => {
render(<Harness mixtures={[]} maxDepth={30} />);
expect(
screen.queryByLabelText(/switch pressure entry/),
).not.toBeInTheDocument();
});
it("brings the toggle back with the first cylinder, already flipped", async () => {
// A diver who flipped pressure on a previous dive: the gate hides the control,
// never the stored choice.
writeEntryUnits({ pressure: "imperial" });
render(<Harness mixtures={[]} maxDepth={30} />);
await userEvent.click(screen.getByRole("button", { name: /add tank/i }));
expect(
screen.getAllByLabelText("bar | psi — switch pressure entry to bar"),
).toHaveLength(2);
expect(screen.getByLabelText("Start pressure (psi)")).toBeInTheDocument();
});
it("hides the toggle again without forgetting the choice", async () => {
writeEntryUnits({ pressure: "imperial" });
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
await userEvent.click(
screen.getByRole("button", { name: /remove tank 1/i }),
);
expect(
screen.queryByLabelText(/switch pressure entry/),
).not.toBeInTheDocument();
expect(parseEntryUnits(readStoredEntryUnits())).toEqual({
pressure: "imperial",
});
});
// The hint's MOD/END/EAD are depths, so they follow the depth entry units - a
// diver typing depths in feet must not be warned about a MOD in metres.
it("works the MOD hint out in the depth entry units", () => {
writeEntryUnits({ depth: "imperial" });
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByText(/MOD [\d.]+ ft @/)).toBeInTheDocument();
expect(screen.queryByText(/MOD [\d.]+ m @/)).not.toBeInTheDocument();
});
// The two dimensions are independent: flipping depth leaves the pressure boxes
// exactly where they were.
it("leaves the pressure boxes alone when depth is the one flipped", () => {
writeEntryUnits({ depth: "imperial" });
render(<Harness mixtures={[EAN54]} maxDepth={30} />);
expect(screen.getByLabelText("Start pressure (bar)")).toBeInTheDocument();
});
});
describe("MixtureFields under a hidden set", () => {
const AIR = { volume: 11.1, oxygen: 21, helium: 0 };
it("takes one input off every tank card", () => {
render(
<Harness
mixtures={[AIR, AIR]}
maxDepth={20}
hidden={["mixture.role", "mixture.po2_limit"]}
/>,
);
expect(screen.getAllByLabelText(/^usage$/i)).toHaveLength(2);
expect(screen.queryByLabelText(/^role$/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/ppO₂ limit/i)).not.toBeInTheDocument();
// The card is still a card: what the cylinder holds never hides.
expect(screen.getAllByLabelText(/O₂ \(%\)/)).toHaveLength(2);
expect(screen.getAllByLabelText(/volume/i)).toHaveLength(2);
});
it("takes the He box off every tank card, and leaves the rest of the mix", () => {
// The gate the "He is hideable" change added. `volume` and `oxygen` stay - they
// are still exempt, on the ground that a cylinder recording neither says nothing
// at all, while one recording no helium is a cylinder of air.
render(
<Harness
mixtures={[AIR, AIR]}
maxDepth={20}
hidden={["mixture.helium"]}
/>,
);
expect(screen.queryByLabelText(/He \(%\)/)).not.toBeInTheDocument();
expect(screen.getAllByLabelText(/O₂ \(%\)/)).toHaveLength(2);
expect(screen.getAllByLabelText(/volume/i)).toHaveLength(2);
});
it("keeps a pressure toggle on the pressure still on screen", () => {
render(
<Harness
mixtures={[AIR]}
maxDepth={20}
hidden={["mixture.start_pressure"]}
/>,
);
expect(
screen.getByRole("button", { name: /switch pressure entry/i }),
).toBeInTheDocument();
});
it("drops the pressure toggle when both pressures are hidden", () => {
render(
<Harness
mixtures={[AIR]}
maxDepth={20}
hidden={["mixture.start_pressure", "mixture.end_pressure"]}
/>,
);
expect(
screen.queryByRole("button", { name: /switch pressure entry/i }),
).not.toBeInTheDocument();
// And the cylinder is still there - only the two boxes and their control went.
expect(screen.getByText(/^tank 1$/i)).toBeInTheDocument();
});
it("still has no pressure toggle when there is no cylinder to convert", () => {
render(<Harness mixtures={[]} maxDepth={20} />);
expect(
screen.queryByRole("button", { name: /switch pressure entry/i }),
).not.toBeInTheDocument();
});
});
describe("Add tank", () => {
it("adds a tank without taking the focus into it", async () => {
// react-hook-form's `append` focuses the first field of the new row that
// registered a focusable ref, and `VolumeCombobox` registers none - so the
// focus skipped Volume and landed on the ppO2 limit `<select>` beside it.
// On iOS that opens the select's picker wheel, which reads as the tap on
// this button having gone through to the field underneath.
render(<Harness mixtures={[]} maxDepth={20} />);
const add = screen.getByRole("button", { name: /add tank/i });
await userEvent.click(add);
expect(screen.getByText(/^tank 1$/i)).toBeInTheDocument();
expect(document.activeElement).toBe(add);
});
});
// The drawing itself is `DiveMixturesCard`'s; what the form adds is that it follows
// the boxes as they are typed into, `""` included.
describe("MixtureFields tank icon", () => {
// `\b` would let `lucide-tank` match `lucide-tank-nitrox`.
function iconClass(tank: number): string {
return screen
.getByText(new RegExp(`^tank ${tank}$`, "i"))
.querySelector("svg")!
.getAttribute("class")!;
}
it("draws a tank added by hand as a single holding air", async () => {
render(<Harness mixtures={[]} maxDepth={20} />);
await userEvent.click(screen.getByRole("button", { name: /add tank/i }));
expect(iconClass(1)).toMatch(/lucide-tank(\s|$)/);
});
it("draws an unrecorded cylinder as a single holding air", () => {
render(
<Harness
mixtures={[{ volume: "", oxygen: "", helium: "" }]}
maxDepth={20}
/>,
);
expect(iconClass(1)).toMatch(/lucide-tank(\s|$)/);
});
it("draws a twin set and its gas from the cylinder's own fields", () => {
render(
<Harness
mixtures={[{ volume: 22.2, oxygen: 18, helium: 45 }]}
maxDepth={60}
/>,
);
expect(iconClass(1)).toMatch(/lucide-twin-tank-trimix\b/);
});
it("draws the first parallel cylinder as the left of the pair", () => {
render(
<Harness
mixtures={[
{ ...EAN54, usage: "staged" },
{ ...EAN54, usage: "parallel" },
{ ...EAN54, usage: "parallel" },
]}
maxDepth={20}
/>,
);
expect([iconClass(1), iconClass(2), iconClass(3)]).toEqual([
expect.stringMatching(/lucide-tank-nitrox\b/),
expect.stringMatching(/lucide-left-tank-nitrox\b/),
expect.stringMatching(/lucide-tank-nitrox\b/),
]);
});
it("redraws as the mix is typed", async () => {
render(<Harness mixtures={[{ ...EAN54 }]} maxDepth={20} />);
const oxygen = screen.getByLabelText(/^o₂/i);
await userEvent.clear(oxygen);
await userEvent.type(oxygen, "21");
expect(iconClass(1)).toMatch(/lucide-tank(\s|$)/);
});
});
describe("MixtureFields cylinder picker", () => {
const vessel = (uuid: string, name: string, volume: number | null) =>
({
uuid,
name,
type: "cylinder",
rented: false,
is_archived: false,
volume,
working_pressure: 232,
material: "steel",
}) satisfies GearItemInfo;
const LEFT = vessel("vessel-left", "Left 12", 12);
const RIGHT = vessel("vessel-right", "Right 12", 12);
const BARE = vessel("vessel-bare", "Unmeasured", null);
beforeEach(() => {
vi.mocked(gearAPI.lookupGearItems).mockReset();
vi.mocked(gearAPI.lookupGearItems).mockResolvedValue({
data: [LEFT, RIGHT, BARE],
total_count: 3,
has_more: false,
page: 1,
items_per_page: 25,
});
});
// The Harness with a way to read what the form would save.
function Saving({
mixtures,
onValues,
}: {
mixtures: DiveFormValues["mixtures"];
onValues: (values: DiveFormValues) => void;
}) {
const form = useForm<DiveFormValues>({
defaultValues: { mixtures, max_depth: 20 } as DiveFormValues,
});
const fieldArray = useMixtureFieldArray(form.control);
return (
<FormProvider {...form}>
<MixtureFields control={form.control} fieldArray={fieldArray} />
<button type="button" onClick={() => onValues(form.getValues())}>
Read
</button>
</FormProvider>
);
}
const pickers = () => screen.getAllByLabelText("Cylinder");
const volumes = () => screen.getAllByLabelText("Volume (L)");
// An option reads its name, then the capacity as a hint: "Left 12, 12 L".
const named = (name: string) => new RegExp(`^${name}(,|$)`);
async function pick(picker: HTMLElement, name: string) {
await userEvent.click(picker);
await userEvent.click(
await screen.findByRole("option", { name: named(name) }),
);
}
it("sits on a row of its own above the tank's boxes", () => {
render(<Harness mixtures={[EAN54]} maxDepth={10} />);
// The field's item: the element holding its label.
const item = screen.getByText("Cylinder", {
selector: "label",
}).parentElement!;
expect(item.className).toContain("md:col-span-2");
// First in the grid, so the eight boxes below keep their pairs.
expect(item.parentElement!.firstElementChild).toBe(item);
expect(item.parentElement!.className).toContain("md:grid-cols-2");
});
it("copies the vessel's capacity on a pick, and reads it off the vessel", async () => {
const onValues = vi.fn();
render(
<Saving mixtures={[{ ...EAN54, volume: 15 }]} onValues={onValues} />,
);
await pick(pickers()[0], "Left 12");
expect(volumes()[0]).toHaveValue("12");
expect(volumes()[0]).toHaveAttribute("readonly");
await userEvent.click(screen.getByText("Read"));
expect(onValues.mock.lastCall![0].mixtures[0]).toMatchObject({
volume: 12,
gear_item_uuid: LEFT.uuid,
gear_item: LEFT,
});
});
it("leaves the figure in place and editable when unlinked", async () => {
const onValues = vi.fn();
render(
<Saving
mixtures={[
{
...EAN54,
volume: "",
gear_item_uuid: LEFT.uuid,
gear_item: LEFT,
},
]}
onValues={onValues}
/>,
);
// A blank stored volume shows the vessel's, which the save would write.
expect(volumes()[0]).toHaveValue("12");
await userEvent.clear(pickers()[0]);
await userEvent.tab();
expect(volumes()[0]).toHaveValue("12");
expect(volumes()[0]).not.toHaveAttribute("readonly");
await userEvent.click(screen.getByText("Read"));
expect(onValues.mock.lastCall![0].mixtures[0]).toMatchObject({
volume: 12,
gear_item_uuid: "",
gear_item: null,
});
});
it("leaves the box editable for a vessel recording no capacity", async () => {
render(<Harness mixtures={[{ ...EAN54, volume: 15 }]} maxDepth={10} />);
await pick(pickers()[0], "Unmeasured");
expect(volumes()[0]).toHaveValue("15");
expect(volumes()[0]).not.toHaveAttribute("readonly");
});
it("does not offer a vessel another tank links, until it is unlinked", async () => {
render(
<Harness
mixtures={[
{ ...EAN54, gear_item_uuid: LEFT.uuid, gear_item: LEFT },
EAN54,
]}
maxDepth={10}
/>,
);
await userEvent.click(pickers()[1]);
await screen.findByRole("option", { name: named("Right 12") });
expect(
screen.queryByRole("option", { name: named("Left 12") }),
).not.toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.clear(pickers()[0]);
await userEvent.tab();
await userEvent.click(pickers()[1]);
expect(
await screen.findByRole("option", { name: named("Left 12") }),
).toBeInTheDocument();
});
it("tells no cylinders apart from every cylinder on another tank", async () => {
vi.mocked(gearAPI.lookupGearItems).mockResolvedValue({
data: [LEFT],
total_count: 1,
has_more: false,
page: 1,
items_per_page: 25,
});
render(
<Harness
mixtures={[
{ ...EAN54, gear_item_uuid: LEFT.uuid, gear_item: LEFT },
EAN54,
]}
maxDepth={10}
/>,
);
await userEvent.click(pickers()[1]);
expect(
await screen.findByText(
"Every cylinder in your gear is already on another tank.",
),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "New gear item..." }),
).toBeInTheDocument();
});
it("says the diver has no cylinders when the lookup lists none", async () => {
vi.mocked(gearAPI.lookupGearItems).mockResolvedValue({
data: [],
total_count: 0,
has_more: false,
page: 1,
items_per_page: 25,
});
render(<Harness mixtures={[EAN54]} maxDepth={10} />);
await userEvent.click(pickers()[0]);
expect(
await screen.findByText("No cylinders in your gear yet."),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "New gear item..." }),
).toBeInTheDocument();
});
it("asks the lookup for cylinders only", async () => {
render(<Harness mixtures={[EAN54]} maxDepth={10} />);
await userEvent.click(pickers()[0]);
await vi.waitFor(() =>
expect(gearAPI.lookupGearItems).toHaveBeenCalledWith(1, 25, {
search: "",
until: undefined,
type: "cylinder",
}),
);
});
it("keeps naming an archived vessel the lookup no longer lists", () => {
render(
<Harness
mixtures={[
{
...EAN54,
gear_item_uuid: "vessel-old",
gear_item: {
...vessel("vessel-old", "Retired 15", 15),
is_archived: true,
},
},
]}
maxDepth={10}
/>,
);
expect(pickers()[0]).toHaveValue("Retired 15");
});
it("is off every tank card while hidden", () => {
render(
<Harness
mixtures={[EAN54, EAN54]}
maxDepth={10}
hidden={["mixture.gear_item_uuid"]}
/>,
);
expect(screen.queryByLabelText("Cylinder")).not.toBeInTheDocument();
});
});