import { afterEach, describe, it, expect, vi } from "vitest"; import { render, screen, within } from "@testing-library/react"; import { DiveGasConsumptionCard } from "./dive-gas-consumption-card"; import type { Dive, DiveMixture, DiveTankGasUse } from "@/lib/api/dives"; import type { UnitSystem } from "@/lib/units"; import { dive as baseDive } from "@/test/dive-fixtures"; // 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 join itself is specified in `dive-gas.test.ts`. What a render adds is the part // that only exists as markup: which of the card's three layouts appears, and whether an // unattributed cylinder and a missing overall SAC read as deliberate rather than as // cells that failed to fill in. function mixture(overrides: Partial = {}): DiveMixture { return { id: 1, volume: 22, start_pressure: 200, end_pressure: 60, oxygen: 21, helium: 0, gas_number: 1, ...overrides, }; } function tank(overrides: Partial = {}): DiveTankGasUse { return { gas_number: 1, gas_used: 3080, rmv: 18.2, sac_bar_per_min: 0.83, seconds_on_gas: 2280, mean_depth: 32.4, ...overrides, }; } const dive = (overrides: Partial = {}) => baseDive({ avg_depth: 26.4, max_depth: 45.91, mixtures: [mixture()], ...overrides, }); // The shape the two-cylinder XML export in the corpus produces: a 21/0 back gas and a // 49/0 deco bottle, breathed at wildly different depths. function twoTankDive(overrides: Partial = {}): Dive { return dive({ mixtures: [ mixture({ role: "bottom" }), mixture({ id: 2, volume: 11, oxygen: 49, gas_number: 2, role: "deco", }), ], gas_use: { gas_used: 3620, rmv: 17.4, sac_bar_per_min: null, tanks: [ tank(), tank({ gas_number: 2, gas_used: 540, rmv: 12.1, sac_bar_per_min: 0.44, seconds_on_gas: 720, mean_depth: 6.4, }), ], attributed_seconds: 3000, duration: 3000, }, ...overrides, }); } // Dive #493 exactly as the API returns it: two mixtures, one transmitter, so the deco // bottle logs no pressures and yields no figure. The commonest multi-gas shape there is - // 19 of 19 in the corpus - and the one a `tanks.length > 1` test would get wrong. function oneTransmitterDive(overrides: Partial = {}): Dive { return dive({ submerged_time: 4001, mixtures: [ mixture({ gas_number: 0, start_pressure: 212, end_pressure: 127 }), mixture({ id: 2, volume: 11.1, oxygen: 54, gas_number: 1, start_pressure: null, end_pressure: null, }), ], gas_use: { gas_used: 1887, rmv: 12.4, sac_bar_per_min: null, tanks: [ tank({ gas_number: 0, gas_used: 1887, rmv: 12.4, sac_bar_per_min: 0.56, seconds_on_gas: 2075, mean_depth: 33.99, }), ], attributed_seconds: 2075, duration: 4300, }, ...overrides, }); } describe("DiveGasConsumptionCard layouts", () => { it("renders nothing for a dive that was never a candidate", () => { const { container } = render( , ); expect(container).toBeEmptyDOMElement(); }); it("keeps the three headline figures for a single-tank dive", () => { // The pre-existing layout, unchanged. The API sends `tanks: []` here - an // empty array, not a null and not an absent key - and a one-row table would // be a worse way to say three numbers. render( , ); expect(screen.getByText("14.29 L/min")).toBeInTheDocument(); expect(screen.getByText("1.19 bar/min")).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); it("writes the headline figures in imperial", () => { // SAC gains a decimal in psi/min and RMV keeps both of its own in cuft/min - // a cubic foot is 28 litres, so the second decimal there is real resolution // rather than the noise it is in L/min. auth.units = "imperial"; render( , ); expect(screen.getByText("0.50 cuft/min")).toBeInTheDocument(); expect(screen.getByText("17.3 psi/min")).toBeInTheDocument(); expect(screen.getByText("108.8 cuft")).toBeInTheDocument(); }); it("explains the absence instead of hiding when nothing could be derived", () => { // Both cylinders carry pressures here, which is the flaggable shape, so the // sentence is the parallel nudge rather than the hand-logged one. That is the // point of where the nudge sits: this dive has no profile, so it must not be // told to import a file it never made. render(); expect( screen.getByText(/breathed alternately at the same depth/i), ).toBeInTheDocument(); }); it("keeps the hand-logged sentence for a pair the flag cannot rescue", () => { // The deco bottle logs no pressures - the corpus's actual multi-gas shape - // so the additive branch could do nothing with it and the existing sentence // stands. It must still not send the diver to a field or blame an import // they never made. render( , ); expect(screen.getByText(/multi-tank/i)).toBeInTheDocument(); expect(screen.getByText(/doesn't have one/)).toBeInTheDocument(); }); it("keeps the average-depth footnote when only the SAC is missing", () => { // This is an additive parallel set of unequal volumes: the pooled SAC is // withheld because a mean drop across two different volumes is not a rate of // anything, while `rmv` and `gas_used` were divided by the dive's own average // depth and the sentence naming it is simply true. // // A null SAC can't gate this sentence: it doesn't mean "derived per cylinder". // What guarantees the claim is position: this arm renders only where `tanks` // came back empty, which is exactly the set of derivations taken against // `dive.avg_depth`. render( , ); expect(screen.getByText(/from an average depth of/)).toBeInTheDocument(); // And the SAC tile shows its muted placeholder rather than vanishing or // leaving a bare unit under the heading. expect(screen.getByText("-")).toBeInTheDocument(); }); it("keeps the average-depth footnote off the per-tank layout entirely", () => { // The same claim, protected by position: an attributed dive renders the // table arm, which does not contain this sentence at all. Pinned so a future // refactor cannot quietly move it there. render(); expect(screen.queryByText(/from an average depth of/)).toBeNull(); }); }); describe("DiveGasConsumptionCard per-tank table", () => { it("gives each cylinder a row carrying its own depth and rate", () => { render(); const rows = screen.getAllByRole("row"); // Header, two cylinders, total. expect(rows).toHaveLength(4); const backGas = within(rows[1]); expect(backGas.getByText("1")).toBeInTheDocument(); expect(backGas.getByText("Air")).toBeInTheDocument(); expect(backGas.getByText("32.4 m")).toBeInTheDocument(); expect(backGas.getByText("18.2 L/min")).toBeInTheDocument(); // The whole point of the split: the deco bottle's rate is its own, taken at // its own 6.4 m rather than at the dive's average depth. const decoGas = within(rows[2]); expect(decoGas.getByText("EAN49")).toBeInTheDocument(); expect(decoGas.getByText("Deco")).toBeInTheDocument(); expect(decoGas.getByText("6.4 m")).toBeInTheDocument(); expect(decoGas.getByText("12.1 L/min")).toBeInTheDocument(); }); it("heads its first two columns with the position and the gas", () => { // The gas is a column rather than a badge pinned to the position, so the // badges line up down the table. render(); const headers = screen .getAllByRole("columnheader") .map((header) => header.textContent); // "#Tank" is the visible character plus the sr-only word behind it. expect(headers.slice(0, 2)).toEqual(["#Tank", "Gas"]); // "Used", not "Gas Used": two headers leading with the same word, one naming // a mix and one a volume, is what the Gas column would otherwise create. expect(headers).toContain("Used"); }); it("dashes the gas of a tank that matches no cylinder", () => { // A `Gas N` row is the device's own tank, not one of the dive's mixtures, so // there is no mix to name in the column beside it. render( , ); const orphan = within(screen.getAllByRole("row")[2]); expect(orphan.getByText("Gas 2")).toBeInTheDocument(); expect(orphan.getByText("-")).toBeInTheDocument(); }); it("totals the dive under the rows and dashes the SAC it cannot state", () => { render(); const total = within(screen.getAllByRole("row")[3]); expect(total.getByText("Total")).toBeInTheDocument(); expect(total.getByText("3620 L")).toBeInTheDocument(); expect(total.getByText("17.4 L/min")).toBeInTheDocument(); // Bar/min across a 22 L twinset and an 11 L stage is not a rate of anything, // so the API sends null and this must not invent a sum. expect(total.queryByText(/bar\/min/)).not.toBeInTheDocument(); }); it("names the missing pressures when that is why a row has no figures", () => { // The corpus's deco bottle with no transmitter, and the only unattributed // row any real dive here renders. Five blank cells would read as a // rendering fault; "Not attributed" would name one of the two states an // empty `use` covers and be contradicted by the coverage note below on the // other. render(); expect(screen.getByText("No pressures recorded")).toBeInTheDocument(); }); it("claims only 'no figures' for a cylinder whose pressures are recorded", () => { // Pressures present, still no row of its own: either the attribution never // named it or the API's per-tank arithmetic declined it, and the browser // cannot tell which. So it says what it knows and no more. render( , ); expect(screen.getByText("No figures")).toBeInTheDocument(); }); it("drops the total when one cylinder would just be restated", () => { // Dive #493's real shape, and every multi-gas dive in the corpus: one // transmitter, so one attributed cylinder beside one that got nothing. A // total here repeats the filled row verbatim and dashes a SAC that row // prints two cells to the left. render(); const rows = screen.getAllByRole("row"); // Header, back gas, deco bottle - and no total. expect(rows).toHaveLength(3); expect(screen.queryByText("Total")).not.toBeInTheDocument(); // The one real SAC still shows; it is only the dive-wide one that doesn't // exist. expect(screen.getByText("0.56 bar/min")).toBeInTheDocument(); }); it("still tables a one-cylinder attribution rather than falling back", () => { // `tanks.length === 1` must not route to the headline layout: the second // cylinder's row is the whole reason this dive needs a table. render(); expect(screen.getByRole("table")).toBeInTheDocument(); expect(screen.getByText("No pressures recorded")).toBeInTheDocument(); }); it("dashes rather than blanks a SAC the API declined to give", () => { // Contract-unreachable today - the API pairs a null SAC with a non-empty // `tanks` - but the layout switch and the nullability are independent, and // an unguarded null leaves a bare "bar/min" under the heading. render( , ); expect(screen.queryByText(/bar\/min/)).not.toBeInTheDocument(); }); it("says <1min rather than 0min for a briefly-breathed cylinder", () => { // Two gas switches close together. Rounded to whole minutes this prints // "0min" beside real litres, which reads as broken rather than brief. render( , ); expect(screen.getByText("<1min")).toBeInTheDocument(); expect(screen.queryByText("0min")).not.toBeInTheDocument(); }); it("stays silent about coverage when the split accounts for the dive", () => { render(); expect(screen.queryByText(/couldn't be assigned/)).not.toBeInTheDocument(); }); it("owns up when the split leaves part of the dive unassigned", () => { render( , ); expect(screen.getByText(/38min of the 42min/)).toBeInTheDocument(); }); });