src/components/dives/dive-gas-consumption-card.render.test.tsx
Rama main · réplica de lectura del repositorio original.
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> = {}): 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> = {}): 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<Dive> = {}) =>
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> = {}): 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> = {}): 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(
<DiveGasConsumptionCard dive={dive({ mixtures: [] })} />,
);
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(
<DiveGasConsumptionCard
dive={dive({
gas_use: {
gas_used: 3080,
rmv: 14.29,
sac_bar_per_min: 1.19,
tanks: [],
attributed_seconds: null,
duration: null,
},
})}
/>,
);
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(
<DiveGasConsumptionCard
dive={dive({
gas_use: {
gas_used: 3080,
rmv: 14.29,
sac_bar_per_min: 1.19,
tanks: [],
attributed_seconds: null,
duration: null,
},
})}
/>,
);
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(<DiveGasConsumptionCard dive={twoTankDive({ gas_use: null })} />);
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(
<DiveGasConsumptionCard
dive={twoTankDive({
gas_use: null,
mixtures: [
mixture({ role: "bottom" }),
mixture({
id: 2,
gas_number: 2,
role: "deco",
start_pressure: null,
end_pressure: null,
}),
],
})}
/>,
);
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(
<DiveGasConsumptionCard
dive={dive({
gas_use: {
gas_used: 3080,
rmv: 14.29,
sac_bar_per_min: null,
tanks: [],
attributed_seconds: null,
duration: null,
},
})}
/>,
);
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(<DiveGasConsumptionCard dive={twoTankDive()} />);
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(<DiveGasConsumptionCard dive={twoTankDive()} />);
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(<DiveGasConsumptionCard dive={twoTankDive()} />);
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(
<DiveGasConsumptionCard
dive={twoTankDive({
mixtures: [mixture({ gas_number: 1 })],
})}
/>,
);
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(<DiveGasConsumptionCard dive={twoTankDive()} />);
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(<DiveGasConsumptionCard dive={oneTransmitterDive()} />);
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(
<DiveGasConsumptionCard
dive={twoTankDive({
mixtures: [
mixture({ role: "bottom" }),
mixture({ id: 2, gas_number: null }),
],
})}
/>,
);
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(<DiveGasConsumptionCard dive={oneTransmitterDive()} />);
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(<DiveGasConsumptionCard dive={oneTransmitterDive()} />);
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(
<DiveGasConsumptionCard
dive={dive({
gas_use: {
gas_used: 3080,
rmv: 14.29,
sac_bar_per_min: null,
tanks: [],
attributed_seconds: null,
duration: null,
},
})}
/>,
);
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(
<DiveGasConsumptionCard
dive={twoTankDive({
gas_use: {
...twoTankDive().gas_use!,
tanks: [tank(), tank({ gas_number: 2, seconds_on_gas: 20 })],
},
})}
/>,
);
expect(screen.getByText("<1min")).toBeInTheDocument();
expect(screen.queryByText("0min")).not.toBeInTheDocument();
});
it("stays silent about coverage when the split accounts for the dive", () => {
render(<DiveGasConsumptionCard dive={twoTankDive()} />);
expect(screen.queryByText(/couldn't be assigned/)).not.toBeInTheDocument();
});
it("owns up when the split leaves part of the dive unassigned", () => {
render(
<DiveGasConsumptionCard
dive={twoTankDive({
gas_use: {
...twoTankDive().gas_use!,
attributed_seconds: 2280,
duration: 2520,
},
})}
/>,
);
expect(screen.getByText(/38min of the 42min/)).toBeInTheDocument();
});
});