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(); return { ...actual, gearAPI: { ...actual.gearAPI, lookupGearItems: vi.fn() }, }; }); vi.mock("@/lib/api/dives", async (importOriginal) => { const actual = await importOriginal(); 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({ defaultValues: { mixtures, max_depth: maxDepth } as DiveFormValues, }); const fieldArray = useMixtureFieldArray(form.control); return ( !hidden.includes(key)} /> ); } 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(); const status = screen.getByRole("status"); expect(status).toBeInTheDocument(); expect(status).toHaveTextContent(""); }); it("announces the warning once it settles", () => { vi.useFakeTimers(); render(); // 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(); 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(); 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(); 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(); 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(); 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(); 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 `` 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( , ); 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(); 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(); expect(screen.getByLabelText(/^role$/i)).toHaveValue(""); expect( // Scoped to this `` 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(); expect(optionsOf(screen.getByLabelText(/^role$/i))).toEqual([ "", "bottom", "deco", "diluent", "oxygen", ]); }); it("shows a role an import recorded", () => { render(); 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(); 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(); 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 `` 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(); 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(); 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( , ); expect(iconClass(1)).toMatch(/lucide-tank(\s|$)/); }); it("draws a twin set and its gas from the cylinder's own fields", () => { render( , ); expect(iconClass(1)).toMatch(/lucide-twin-tank-trimix\b/); }); it("draws the first parallel cylinder as the left of the pair", () => { render( , ); 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(); 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({ defaultValues: { mixtures, max_depth: 20 } as DiveFormValues, }); const fieldArray = useMixtureFieldArray(form.control); return ( ); } 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(); // 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( , ); 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( , ); // 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(); 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( , ); 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( , ); 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(); 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(); 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( , ); expect(pickers()[0]).toHaveValue("Retired 15"); }); it("is off every tank card while hidden", () => { render(