import { describe, expect, it } from "vitest"; import type { Dive, DiveGasUse, DiveMixture, DiveTankGasUse, Recording, } from "@/lib/api/dives"; import { bandRanges, binStart, binSummaries, binUnit, clipOutliers, gasAttributionNote, gasUseUnavailableReason, quantile, quartiles, scopeRange, summarizeGasUse, tankGasUseRows, } from "@/lib/dive-gas"; // `periodRange`/`periodLabel`/`stepPeriod`/`availablePeriods` moved to // `chart-period.ts` with their tests; `periodRange` stays imported here because // `scopeRange` and `bandRanges` are specified against it. import { type ChartScope, periodRange } from "@/lib/chart-period"; import { dive as baseDive } from "@/test/dive-fixtures"; function mixture(overrides: Partial = {}): DiveMixture { return { id: 1, volume: 12, start_pressure: 200, end_pressure: 50, oxygen: 21, helium: 0, ...overrides, }; } const dive = (overrides: Partial = {}) => baseDive({ avg_depth: 18, mixtures: [mixture()], ...overrides }); // Enough of a recording to say "this dive was imported and a profile came out of // it", which is the only thing `gasUseUnavailableReason` asks of it. Without one // there is no `gas_attribution` column for the multi-tank derivation to read, so // every fixture below that is *about* a later branch has to carry it. // // **Ordinal 0**, because that is the only recording the API's attribution query // looks at: a profile on a second computer's recording does not put a // `gas_attribution` on the dive, and a fixture that passed with one anywhere // would be asserting a rule the server does not have. function recorded(): Recording[] { return [ { uuid: "r1", ordinal: 0, files: [], profile: { uuid: "p1", duration: 4_300_000, depth_sample_count: 431, provenance: "file", channels: ["depth"], }, }, ]; } function tank(overrides: Partial = {}): DiveTankGasUse { return { gas_number: 1, gas_used: 1800, rmv: 18.2, sac_bar_per_min: 1.5, seconds_on_gas: 1800, mean_depth: 32.4, ...overrides, }; } function multiTankUse( tanks: DiveTankGasUse[], overrides: Partial = {}, ): DiveGasUse { return { gas_used: tanks.reduce((sum, entry) => sum + entry.gas_used, 0), rmv: 17.4, // Null on an attributed multi-tank dive - see `DiveGasUse.sac_bar_per_min`, // whose one exception is the additive parallel path with equal volumes, which // this helper does not build. sac_bar_per_min: null, tanks, attributed_seconds: tanks.reduce( (sum, entry) => sum + entry.seconds_on_gas, 0, ), duration: tanks.reduce((sum, entry) => sum + entry.seconds_on_gas, 0), ...overrides, }; } describe("gasUseUnavailableReason", () => { it("says nothing when the API already derived a figure", () => { const derived = dive({ gas_use: { gas_used: 1800, rmv: 14.29, sac_bar_per_min: 1.19 }, }); expect(gasUseUnavailableReason(derived)).toBeNull(); }); it("says nothing for a dive that logs no tank at all", () => { // Never a candidate, so there is no absence to explain - a freedive or an // old entry logged before mixtures were filled in shouldn't grow a nag. expect(gasUseUnavailableReason(dive({ mixtures: [] }))).toBeNull(); }); it("says nothing for a dive from the list response", () => { // The list carries neither `mixtures` nor `gas_use`, so without the guard // every row would claim its pressures were missing. const listDive = dive(); delete (listDive as Partial).mixtures; expect(gasUseUnavailableReason(listDive)).toBeNull(); }); it("names multi-tank as a limitation, not a missing field", () => { // A cylinder with no pressures, which is what keeps this on the multi-tank // sentences. Not a plain pair - `mixture()` defaults every row to 200/50 - // because that shape is the nudge's, being exactly what a flaggable sidemount // pair looks like; that case is the test immediately after this one. const reason = gasUseUnavailableReason( dive({ mixtures: [ mixture(), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ); expect(reason).toMatch(/multi-tank/i); // It must not ask the diver to add anything - the data is all there, and // what's missing is an import that says which tank was breathed when. expect(reason).not.toMatch(/^Add /); expect(reason).toMatch(/import/i); }); it("nudges an unflagged pair that has every pressure toward the parallel flag", () => { // The other half of the split above, and the whole point of the nudge's // position: this dive is hand-logged with no profile, so it must not be told // to import a dive-computer file it never made. Everything the additive // branch needs is on the row already except the diver's answer. const reason = gasUseUnavailableReason( dive({ mixtures: [mixture(), mixture({ id: 2 })] }), ); expect(reason).toMatch(/parallel/i); // It states the condition rather than instructing. A diver whose pair really // was staged must not be walked into a flag that divides the bottle's litres // by the whole dive's average depth. expect(reason).toMatch(/^If these cylinders were breathed alternately/); expect(reason).not.toMatch(/import/i); }); it("still nudges a half-flagged pair rather than falling silent on it", () => { // One row Parallel, one still Not recorded - the likeliest path into the // feature, and the reason the suppressing gate keys on `staged` rather than // on any explicit `usage`. Silencing this drops the diver onto the // no-profile message, which is wrong advice about an import they never made. const reason = gasUseUnavailableReason( dive({ mixtures: [mixture({ usage: "parallel" }), mixture({ id: 2 })], }), ); expect(reason).toMatch(/^If these cylinders were breathed alternately/); }); it("says nothing about parallel once a cylinder is explicitly staged", () => { // A pair plus a bottle the diver marked Staged is refused by design, and // someone who used the control exactly right must not be nudged on every // render. The `staged` flag is that set's tell. const reason = gasUseUnavailableReason( dive({ mixtures: [ mixture({ usage: "parallel" }), mixture({ id: 2, usage: "parallel" }), mixture({ id: 3, usage: "staged" }), ], }), ); expect(reason).not.toMatch(/marking each of them Parallel/); expect(reason).toMatch(/doesn't have one/); }); it("keeps a pressure-less pair on its existing reasons rather than nudging", () => { // Gate 1. A bottle with no pressures is the tell of the corpus's commonest // multi-gas shape - back gas plus a staged deco bottle - and nudging it would // re-create the tell-then-refuse-again pattern the no-profile branch was // placed first to stop. const reason = gasUseUnavailableReason( dive({ mixtures: [ mixture(), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ); expect(reason).not.toMatch(/Parallel/); }); it("does not nudge a pair that records no drop at all", () => { // The other half of gate 1: pressures on both, nothing out of either. There // is nothing for the flag to add up, so the existing sentence stands. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ start_pressure: 200, end_pressure: 200 }), mixture({ id: 2, start_pressure: 150, end_pressure: 150 }), ], }), ); expect(reason).not.toMatch(/Parallel/); expect(reason).toMatch(/no gas used to divide up/); }); it("blames attribution on the shape the whole corpus actually has", () => { // One transmitter on the back gas, none on the deco bottle - every multi-gas // dive in the corpus. The pressures that exist are usable, so the missing // piece is the attribution, not anything the diver left out. const oneTransmitter = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ gas_number: 0 }), mixture({ id: 2, gas_number: 1, start_pressure: null, end_pressure: null, }), ], }), ); expect(oneTransmitter).toMatch(/multi-tank/i); expect(oneTransmitter).not.toMatch(/^Add /); // It must not claim the import records no gas switches. Two of the three // refusals this sentence covers happen on dives that *do* record them - a // breathed cylinder the switches never named, and a switch timed so late the // per-tank RMV comes out impossible - and on those the profile chart is // drawing the switch markers ten lines up the same page. expect(oneTransmitter).not.toMatch(/records which tank/); expect(oneTransmitter).toMatch(/account for every cylinder/); }); it("blames the sizes, not the switches, when no breathed cylinder has one", () => { // `_tank_arithmetic` drops an unsized cylinder however good the attribution is: // it was counted as breathed and then produced no litres, so a dive whose // breathed cylinders are all unsized comes back empty for a reason the gas // switches have nothing to do with. Blaming them would name the one input that // is not the problem, while the profile chart draws the switches ten lines up // the same page. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ gas_number: 0, volume: null }), mixture({ id: 2, gas_number: 1, volume: null, start_pressure: null, end_pressure: null, }), ], }), ); expect(reason).toBe( "Add each cylinder's size to see your gas consumption.", ); expect(reason).not.toMatch(/account for every cylinder/); }); it("still blames attribution when one breathed cylinder does have a size", () => { // The mixed case, and the reason the narrowing is on the *breathed* set rather // than on all of them: a sized, breathed cylinder can produce a figure the // moment the attribution names it, so the attribution really is what is // missing. The unsized one beside it is a separate loss the API reports as a // coverage shortfall, not as a refusal. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ gas_number: 0 }), mixture({ id: 2, gas_number: 1, volume: null, start_pressure: null, end_pressure: null, }), ], }), ); expect(reason).toMatch(/account for every cylinder/); }); it("names an unbreathed pair on a multi-tank dive rather than asking for it", () => { // Pressures present on every cylinder and equal on all of them, so the API's // per-tank arithmetic drops each in turn and the dive comes back empty. // "Add your pressures" would be wrong twice: they are there, and adding more // wouldn't help. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ start_pressure: 200, end_pressure: 200 }), mixture({ id: 2, start_pressure: 150, end_pressure: 150 }), ], }), ); expect(reason).toMatch(/no gas used to divide up/); expect(reason).not.toMatch(/^Add /); }); it("asks a multi-tank dive for pressures when no cylinder has a pair", () => { // The one multi-tank case the diver can act on. Both cylinders bare, so the // attribution inference has nothing to go on either way and the pressures // are needed whatever the profile turns out to hold. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ start_pressure: null, end_pressure: null }), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ); expect(reason).toBe( "Add each cylinder's start and end pressure to see your gas consumption.", ); // Never the singular sentence the one-tank branch uses - "this tank's" // points at a row the diver is not looking at. expect(reason).not.toMatch(/this tank/i); // The multi-tank path takes depth per cylinder from the profile, so asking // for an average depth would send the diver to a field that changes nothing. expect(reason).not.toMatch(/average depth/i); }); it("blames attribution when only one of several cylinders has pressures", () => { // Half-filled is enough to rule the pressures out as the blocker: the API // would have produced a partial result from the cylinder it could measure. const reason = gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture(), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ); expect(reason).toMatch(/multi-tank/i); }); it("doesn't ask a hand-logged multi-tank dive for anything", () => { // 18 of the 19 multi-gas dives in the corpus: logged by hand, so no source // file and no profile - and `gas_attribution` is a column *on* the profile. // Nothing the diver types can produce a figure, so the sentence must not // send them to a field, nor imply their import is deficient when they made // none. Both pressures deliberately present on one cylinder, so the // attribution branch would otherwise have claimed exactly that. const handLogged = gasUseUnavailableReason( dive({ mixtures: [ mixture(), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ); expect(handLogged).toMatch(/doesn't have one/); expect(handLogged).not.toMatch(/^Add /); expect(handLogged).not.toMatch(/account for every cylinder/); }); it("asks for pressures only once a profile could actually use them", () => { // The same bare dive with an import behind it. Now the pressures are worth // asking for, because there is a profile whose switches might attribute // them. expect( gasUseUnavailableReason( dive({ recordings: recorded(), mixtures: [ mixture({ start_pressure: null, end_pressure: null }), mixture({ id: 2, start_pressure: null, end_pressure: null }), ], }), ), ).toMatch(/^Add each cylinder/); }); // A flagged pair, which is the shape every case in this block starts from. // `parallel()` names it rather than each test spelling both rows out, since // what each is about is the *one* thing missing from it. function parallelPair( first: Partial = {}, second: Partial = {}, ): DiveMixture[] { return [ mixture({ usage: "parallel", ...first }), mixture({ id: 2, usage: "parallel", ...second }), ]; } it("asks a flagged parallel pair for the depth and the pressures together", () => { // The opening state of a sidemount diver's every next dive: the new-dive form // carries `usage` over from the last one, so all-parallel-with-nothing-else is // what they see before typing anything. Two sequential asks here would make // the commonest state of the form a run of refusals. const reason = gasUseUnavailableReason( dive({ avg_depth: undefined, mixtures: parallelPair( { start_pressure: null, end_pressure: null }, { start_pressure: null, end_pressure: null }, ), }), ); expect(reason).toBe( "Add an average depth and every cylinder's start and end pressure to see your gas consumption.", ); }); it("asks a flagged parallel pair for an average depth on its own", () => { // The round-3 finding this block exists for. Full pressures, no depth - the // additive branch divides by `dive.avg_depth`, so it is genuinely the missing // input. Without this the dive falls to the no-profile message and is told to // import a dive-computer file, which would compute nothing for it. const reason = gasUseUnavailableReason( dive({ avg_depth: undefined, mixtures: parallelPair() }), ); expect(reason).toBe("Add an average depth to see your gas consumption."); expect(reason).not.toMatch(/import/i); }); it("refuses a flagged pair on one missing pressure, and says every cylinder", () => { // One null pressure anywhere refuses the whole set: that cylinder's litres // would be missing from the numerator while the whole dive stayed in the // denominator, reporting an RMV that is too low. const reason = gasUseUnavailableReason( dive({ mixtures: parallelPair( {}, { start_pressure: null, end_pressure: null }, ), }), ); expect(reason).toBe( "Add every cylinder's start and end pressure to see your gas consumption.", ); // Half a pair is still a missing pressure, and a real form state: a start // typed and the end not yet. Both halves of the test are checked, so a guard // that only ever looked at `start_pressure` would pass the case above. expect( gasUseUnavailableReason( dive({ mixtures: parallelPair({}, { end_pressure: null }) }), ), ).toBe( "Add every cylinder's start and end pressure to see your gas consumption.", ); }); it("says a flagged pair has nothing to add up when no cylinder dropped", () => { // "Add up", not "divide up" - nothing is apportioned on this path. A single // zero-drop row is fine and sums to zero litres; only a zero *total* refuses. const reason = gasUseUnavailableReason( dive({ mixtures: parallelPair( { start_pressure: 200, end_pressure: 200 }, { start_pressure: 150, end_pressure: 150 }, ), }), ); expect(reason).toBe( "No cylinder on this dive records a drop between its start and end pressure, so there is no gas used to add up.", ); }); it("refuses a flagged pair on one unsized cylinder, and says every cylinder", () => { // One cylinder with no recorded size contributes no litres to a sum whose // denominator still covers the whole dive - an RMV too low rather than none at // all, which is the same refusal the missing pressure above gets. const reason = gasUseUnavailableReason( dive({ mixtures: parallelPair({}, { volume: null }) }), ); expect(reason).toBe( "Add every cylinder's size to see your gas consumption.", ); }); it("asks a flagged pair for the size before the pressures", () => { // The reverse of the single-cylinder order, and deliberately so: each branch // follows the guard order of the API function it mirrors, and // `compute_parallel_gas_use` refuses an unsized cylinder before it looks at any // pressure. const reason = gasUseUnavailableReason( dive({ mixtures: parallelPair( { volume: null }, { start_pressure: null, end_pressure: null }, ), }), ); expect(reason).toBe( "Add every cylinder's size to see your gas consumption.", ); }); it("says nothing at all for a flagged pair with nothing missing", () => { // Unreachable through the app - a set that passes every additive guard has a // figure, so this function is not called for it - and pinned anyway, because // the tempting alternative is to let it fall through to the branches below. // That would hand a flagged sidemount pair "import a dive-computer file", // which is the one sentence this whole block exists to keep away from it. expect( gasUseUnavailableReason(dive({ mixtures: parallelPair() })), ).toBeNull(); }); it("never sends a flagged pair to the no-profile or attribution sentences", () => { // The whole reason this block precedes them. None of those sentences is true // of a derivation that reads no profile at all. for (const mixtures of [ parallelPair({ start_pressure: null, end_pressure: null }), parallelPair( { start_pressure: 200, end_pressure: 200 }, { start_pressure: 150, end_pressure: 150 }, ), ]) { const reason = gasUseUnavailableReason(dive({ mixtures })); expect(reason).not.toMatch(/import/i); expect(reason).not.toMatch(/account for every cylinder/); } }); it("asks for an average depth when only that is missing", () => { expect(gasUseUnavailableReason(dive({ avg_depth: undefined }))).toBe( "Add an average depth to see your gas consumption.", ); }); it("treats a zero or negative average depth as missing", () => { expect(gasUseUnavailableReason(dive({ avg_depth: 0 }))).toMatch( /average depth/, ); }); it("asks for pressures when only those are missing", () => { const noStart = dive({ mixtures: [mixture({ start_pressure: undefined })], }); const noEnd = dive({ mixtures: [mixture({ end_pressure: undefined })] }); expect(gasUseUnavailableReason(noStart)).toMatch(/start and end pressure/); expect(gasUseUnavailableReason(noEnd)).toMatch(/start and end pressure/); }); it("asks for both when both are missing", () => { const bare = dive({ avg_depth: undefined, mixtures: [ mixture({ start_pressure: undefined, end_pressure: undefined }), ], }); expect(gasUseUnavailableReason(bare)).toBe( "Add an average depth and this tank's start and end pressure to see your gas consumption.", ); }); it("explains an unbreathed tank when the pressures are equal", () => { const untouched = dive({ mixtures: [mixture({ start_pressure: 200, end_pressure: 200 })], }); expect(gasUseUnavailableReason(untouched)).toMatch(/no gas used/); }); it("asks a single cylinder for its size when that is all that is missing", () => { // The mix-only cylinder: a UDDF `` with a gas link and no // ``, on a dive that carries an average depth, both pressures and a // real drop between them. Every other input `compute_gas_use` wants is present, // so this is the only refusal left to reach - and before the column was nullable // the cylinder was skipped on import and the dive had no gas card at all. const mixOnly = dive({ mixtures: [mixture({ volume: null })] }); expect(gasUseUnavailableReason(mixOnly)).toBe( "Add this tank's size to see your gas consumption.", ); }); it("asks for the size before saying the pressures show no drop", () => { // `compute_gas_use` checks the volume ahead of the drop, so a cylinder with no // size and no drop is refused for the size. Both are true; the order is the // API's, and the size is the one the diver can do something about. const neither = dive({ mixtures: [ mixture({ volume: null, start_pressure: 200, end_pressure: 200 }), ], }); expect(gasUseUnavailableReason(neither)).toMatch(/size/); }); it("asks for the pressures before the size, unlike a flagged pair", () => { // The single-cylinder order, and the mirror of the parallel test below: with no // pressures *and* no size, one cylinder is asked for its pressures because // `compute_gas_use` refuses on those first. const bare = dive({ mixtures: [ mixture({ volume: null, start_pressure: null, end_pressure: null }), ], }); expect(gasUseUnavailableReason(bare)).toBe( "Add this tank's start and end pressure to see your gas consumption.", ); }); }); describe("tankGasUseRows", () => { it("is empty for a dive the API derived the single-tank way", () => { // No `tanks` on the wire, so there is no split to render and the card keeps // its three headline figures. This emptiness is the switch between the two // layouts, not just an absence. const single = dive({ gas_use: { gas_used: 1800, rmv: 14.29, sac_bar_per_min: 1.19 }, }); expect(tankGasUseRows(single)).toEqual([]); }); it("joins each tank to its cylinder and keeps the dive's own order", () => { const withTanks = dive({ mixtures: [ mixture({ id: 1, gas_number: 1, role: "bottom" }), mixture({ id: 2, gas_number: 2, oxygen: 50, role: "deco", }), ], gas_use: multiTankUse([ // Deliberately the reverse of the dive's order: the API is free to emit // tanks in whatever order it walked the profile in, and the table has to // read against the Tanks card above it regardless. tank({ gas_number: 2, gas_used: 400, rmv: 12.1, mean_depth: 6.4 }), tank({ gas_number: 1 }), ]), }); const rows = tankGasUseRows(withTanks); expect(rows.map((row) => row.label)).toEqual(["1", "2"]); expect(rows.map((row) => row.gas)).toEqual(["Air", "EAN50"]); expect(rows.map((row) => row.role)).toEqual(["bottom", "deco"]); expect(rows.map((row) => row.use?.gas_number)).toEqual([1, 2]); }); it("joins on gas number 0, which a Suunto Ocean really uses", () => { // The zero trap: a falsy gas number is a real label, and every `!number` // shortcut in this join would drop the whole first cylinder of an Ocean // export. const ocean = dive({ mixtures: [mixture({ gas_number: 0 })], gas_use: multiTankUse([tank({ gas_number: 0 })]), }); expect(tankGasUseRows(ocean)[0].use?.gas_number).toBe(0); }); it("names a cylinder by its position, never by its gas number", () => { // The Tanks card's number, so the two agree on what to call a tank - and // 1-based position, which a Suunto Ocean's 0-based numbering is off by one // from all the way down. const ocean = dive({ mixtures: [ mixture({ id: 1, gas_number: 0 }), mixture({ id: 2, gas_number: 1 }), ], gas_use: multiTankUse([tank({ gas_number: 0 }), tank({ gas_number: 1 })]), }); expect(tankGasUseRows(ocean).map((row) => row.label)).toEqual(["1", "2"]); }); it("attributes nothing to either of two cylinders sharing a gas number", () => { // The failure this join exists to prevent. A naive lookup hands the same // tank to both rows, showing one cylinder's litres twice under a total that // counted them once - a table that visibly doesn't add up. const ambiguous = dive({ mixtures: [ mixture({ id: 1, gas_number: 1 }), mixture({ id: 2, gas_number: 1 }), ], gas_use: multiTankUse([tank({ gas_number: 1 })]), }); const rows = tankGasUseRows(ambiguous); expect(rows.slice(0, 2).map((row) => row.use)).toEqual([null, null]); // And the now-orphaned tank still appears, so its litres are visible // somewhere rather than only inside the total. expect(rows).toHaveLength(3); expect(rows[2].label).toBe("Gas 1"); expect(rows[2].use?.gas_number).toBe(1); }); it("attributes nothing from two tanks sharing a gas number either", () => { // The mirror of the case above, and the worse one: a lookup keyed by gas // number keeps the last writer, so the 100 L tank would match nothing, be // skipped by an append loop testing the number rather than the tank, and // vanish - while its litres stayed inside the dive-wide total. const ambiguous = dive({ mixtures: [mixture({ gas_number: 1 })], gas_use: multiTankUse([ tank({ gas_number: 1, gas_used: 100 }), tank({ gas_number: 1, gas_used: 50 }), ]), }); const rows = tankGasUseRows(ambiguous); expect(rows[0].use).toBeNull(); // Every litre the total claims is visible in a row. expect(rows.reduce((sum, row) => sum + (row.use?.gas_used ?? 0), 0)).toBe( 150, ); expect(new Set(rows.map((row) => row.key)).size).toBe(rows.length); }); it("puts every tank in exactly one row, whatever the join does", () => { // The invariant the whole function rests on, and what makes "the visible // rows sum to the stated total" checkable rather than asserted. const messy = dive({ mixtures: [ mixture({ id: 1, gas_number: 0 }), mixture({ id: 2, gas_number: 4 }), mixture({ id: 3, gas_number: 4 }), mixture({ id: 4, gas_number: null }), ], gas_use: multiTankUse([ tank({ gas_number: 0 }), tank({ gas_number: 4 }), tank({ gas_number: 9 }), ]), }); const rows = tankGasUseRows(messy); const placed = rows.map((row) => row.use).filter((use) => use !== null); expect(placed).toHaveLength(3); expect(new Set(placed).size).toBe(3); }); it("leaves a cylinder with no gas number unattributed", () => { // A hand-added cylinder on an imported dive. Nothing to join on, and // guessing by position is exactly the guess the API refuses to make. const handAdded = dive({ mixtures: [mixture({ gas_number: null })], gas_use: multiTankUse([tank({ gas_number: 1 })]), }); const rows = tankGasUseRows(handAdded); expect(rows[0].use).toBeNull(); expect(rows[1].label).toBe("Gas 1"); }); it("appends a tank matching no cylinder rather than dropping it", () => { // Its litres are already inside the dive-wide total, so a hidden row would // leave a total the visible rows don't sum to. const orphan = dive({ mixtures: [mixture({ gas_number: 1 })], gas_use: multiTankUse([tank({ gas_number: 1 }), tank({ gas_number: 7 })]), }); const rows = tankGasUseRows(orphan); expect(rows).toHaveLength(2); expect(rows[1].label).toBe("Gas 7"); expect(rows[1].gas).toBeNull(); expect(rows[1].use?.gas_number).toBe(7); }); it("gives every row a distinct key", () => { // Two unnumbered cylinders match no tank at all; React still needs to tell // their rows apart. const twins = dive({ mixtures: [ mixture({ id: 1, gas_number: null }), mixture({ id: 2, gas_number: null }), ], gas_use: multiTankUse([tank({ gas_number: 1 })]), }); const keys = tankGasUseRows(twins).map((row) => row.key); expect(new Set(keys).size).toBe(keys.length); }); }); describe("gasAttributionNote", () => { it("says nothing when the split covers the whole recorded dive", () => { expect( gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 2700, duration: 2700, }), ), ).toBeNull(); }); it("says nothing when the remainder is under a minute of rounding", () => { expect( gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 2655, duration: 2700, }), ), ).toBeNull(); }); it("names both spans once a minute or more is unassigned", () => { const note = gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 2280, duration: 2520, }), ); expect(note).toBe( "These figures cover 38min of the 42min the dive computer recorded - the rest couldn't be assigned to a cylinder.", ); }); it("attributes the denominator, which outruns the dive's own dive time", () => { // Dive #493's real numbers: 2075 s attributed against a 4300 s profile span, // on a dive whose logged dive time is 4001 s and prints as "1h 7min" at the // top of the same page. Unattributed, "of the 1h 12min recorded" reads as // contradicting that header rather than as the computer's own span. const note = gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 2075, duration: 4300, }), ); expect(note).toMatch(/35min of the 1h 12min the dive computer recorded/); }); it("says nothing when the API sent no coverage figures", () => { // Every single-tank dive, and any older response predating the pair. expect( gasAttributionNote({ gas_used: 1800, rmv: 14.29, sac_bar_per_min: 1.19 }), ).toBeNull(); }); it("says nothing rather than claiming more of the dive than it has", () => { // A degenerate profile, or attribution that overlapped itself. Silence beats // "covers 45min of the 40min recorded". expect( gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 2700, duration: 2400, }), ), ).toBeNull(); expect( gasAttributionNote( multiTankUse([tank()], { attributed_seconds: 0, duration: 0, }), ), ).toBeNull(); }); }); describe("bandRanges", () => { it("shades alternate months of a year, starting at February", () => { const bands = bandRanges( "year", periodRange(Date.UTC(2026, 3, 17), "year"), ); expect(bands).toHaveLength(6); expect(bands[0]).toEqual({ start: Date.UTC(2026, 1, 1), end: Date.UTC(2026, 2, 1), }); expect(bands[5].end).toBe(Date.UTC(2027, 0, 1)); }); it("shades alternate years across a career", () => { const range = { start: Date.UTC(2023, 5, 1), end: Date.UTC(2026, 2, 1) }; expect(bandRanges("all", range)).toEqual([ { start: Date.UTC(2024, 0, 1), end: Date.UTC(2025, 0, 1) }, // Clamped: the series ends in March, so the 2026 band does too. { start: Date.UTC(2026, 0, 1), end: Date.UTC(2026, 2, 1) }, ]); }); it("never runs outside the plotted range", () => { const range = { start: Date.UTC(2023, 5, 1), end: Date.UTC(2026, 2, 1) }; for (const band of bandRanges("all", range)) { expect(band.start).toBeGreaterThanOrEqual(range.start); expect(band.end).toBeLessThanOrEqual(range.end); expect(band.end).toBeGreaterThan(band.start); } }); it("drops a band a partial year leaves no room for", () => { // A career that starts in December of its second (shaded) year: the band // would be a sliver behind the axis, not a readable block. const range = { start: Date.UTC(2025, 0, 1), end: Date.UTC(2026, 0, 1) }; expect(bandRanges("all", range)).toEqual([]); }); it("does not subdivide a month", () => { expect( bandRanges("month", periodRange(Date.UTC(2026, 3, 17), "month")), ).toEqual([]); }); }); describe("summarizeGasUse", () => { // Two dives in April 2025 with a median of 20, two in October with a median // of 15, one in March 2026 at 12. const times = [ Date.UTC(2025, 3, 10), Date.UTC(2025, 3, 12), Date.UTC(2025, 9, 5), Date.UTC(2025, 9, 7), Date.UTC(2026, 2, 1), ]; const rmvs = [18, 22, 14, 16, 12]; it("takes the median of the whole series for the all scope", () => { const summary = summarizeGasUse(times, rmvs, "all", times[4]); // 16, not the mean's 16.4. expect(summary).toMatchObject({ dives: 5, median: 16, best: 12 }); }); it("has nothing to compare the all scope against", () => { const summary = summarizeGasUse(times, rmvs, "all", times[4]); expect(summary?.changePercent).toBeNull(); expect(summary?.previousLabel).toBeNull(); }); it("summarizes only the anchored period", () => { const summary = summarizeGasUse(times, rmvs, "month", times[0]); expect(summary).toMatchObject({ dives: 2, median: 20, best: 18 }); }); it("reports an improvement as a negative change", () => { // October's median of 15 against April's 20. Lower is better, so this is a // 25% improvement and the sign has to say so. const summary = summarizeGasUse(times, rmvs, "month", times[2]); expect(summary?.changePercent).toBeCloseTo(-25); expect(summary?.previousLabel).toBe("Apr 2025"); }); it("compares against the previous period with dives, not the previous calendar one", () => { // March 2026's predecessor is October 2025, five empty months back - "vs // February" would be comparing against nothing. const summary = summarizeGasUse(times, rmvs, "month", times[4]); expect(summary?.previousLabel).toBe("Oct 2025"); expect(summary?.changePercent).toBeCloseTo(-20); }); it("compares years when the scope is a year", () => { const summary = summarizeGasUse(times, rmvs, "year", times[4]); expect(summary?.previousLabel).toBe("2025"); // 12 against 2025's median of 17. expect(summary?.changePercent).toBeCloseTo(-29.41, 1); }); it("has nothing to compare the earliest period against", () => { const summary = summarizeGasUse(times, rmvs, "month", times[0]); expect(summary?.changePercent).toBeNull(); expect(summary?.previousLabel).toBeNull(); }); it("returns null for a period with no dives", () => { expect(summarizeGasUse([], [], "all", 0)).toBeNull(); expect( summarizeGasUse(times, rmvs, "month", Date.UTC(2025, 6, 1)), ).toBeNull(); }); }); describe("scopeRange", () => { // Callers filter with `time >= start && time < end`, so these tests assert on // what actually survives that filter rather than on the bounds themselves - // the bug this replaced had perfectly reasonable-looking bounds. const visible = (times: number[], anchor: number, scope: ChartScope) => { const range = scopeRange(times, anchor, scope); return times.filter((time) => time >= range.start && time < range.end); }; it("delegates to periodRange for year and month", () => { const anchor = Date.UTC(2026, 3, 17); expect(scopeRange([anchor], anchor, "year")).toEqual( periodRange(anchor, "year"), ); expect(scopeRange([anchor], anchor, "month")).toEqual( periodRange(anchor, "month"), ); }); // The regression: an "all" range ending at the last dive's own timestamp // dropped that dive from the plot, so the chart always showed one fewer dive // than the card's "Dives" stat above it. it("includes the newest dive at the 'all' scope", () => { const times = [ Date.UTC(2025, 3, 10), Date.UTC(2025, 8, 2), Date.UTC(2026, 3, 17), ]; expect(visible(times, times[0], "all")).toEqual(times); }); it("includes every dive tied at the newest timestamp", () => { const last = Date.UTC(2026, 3, 17); const times = [Date.UTC(2025, 3, 10), last, last]; expect(visible(times, times[0], "all")).toEqual(times); }); it("handles a single-dive series", () => { const only = Date.UTC(2026, 3, 17); expect(visible([only], only, "all")).toEqual([only]); }); it("returns a degenerate, non-NaN range for an empty series", () => { const range = scopeRange([], 0, "all"); expect(Number.isNaN(range.start)).toBe(false); expect(Number.isNaN(range.end)).toBe(false); expect(range.end).toBeGreaterThan(range.start); }); }); describe("quantile", () => { it("interpolates between the ranks it falls between", () => { // R-7: the median of an even count is the mean of the middle two, and a // quartile a quarter of the way along an interval reads a quarter of it. expect(quantile([1, 2, 3, 4], 0.5)).toBe(2.5); expect(quantile([10, 20, 30, 40, 50], 0.25)).toBe(20); expect(quantile([3, 1, 2], 0.5)).toBe(2); }); it("reads the ends as the extremes", () => { expect(quantile([4, 9, 1], 0)).toBe(1); expect(quantile([4, 9, 1], 1)).toBe(9); expect(quantile([7], 0.5)).toBe(7); }); it("moves a quartile by at most a rank when one dive is wild", () => { const steady = quartiles([12, 12, 13, 13, 14, 14, 15, 15]); const spiked = quartiles([12, 12, 13, 13, 14, 14, 15, 40]); expect(spiked.median).toBe(steady.median); expect(spiked.q3).toBe(steady.q3); }); }); describe("clipOutliers", () => { it("keeps every dive of a series under a hundred", () => { const values = Array.from({ length: 99 }, (_, index) => index); expect(clipOutliers(values)).toBe(values); }); it("drops one dive in a hundred from each end", () => { const values = Array.from({ length: 250 }, (_, index) => 250 - index); const kept = clipOutliers(values); expect(kept).toHaveLength(246); expect(Math.min(...kept)).toBe(3); expect(Math.max(...kept)).toBe(248); }); }); describe("binUnit", () => { const DAY = 24 * 60 * 60 * 1000; it("picks days for a month, weeks for a year and months for a career", () => { expect(binUnit(30 * DAY, 664)).toBe("day"); expect(binUnit(365 * DAY, 664)).toBe("week"); expect(binUnit(4 * 365 * DAY, 664)).toBe("month"); expect(binUnit(12 * 365 * DAY, 664)).toBe("quarter"); }); it("steps up a unit when the axis narrows to a phone", () => { // A year on a 426-unit plot gives a week 8 units, under the floor. expect(binUnit(365 * DAY, 426)).toBe("month"); }); it("never goes finer than a day or coarser than a year", () => { expect(binUnit(DAY, 664)).toBe("day"); expect(binUnit(100 * 365 * DAY, 664)).toBe("year"); }); }); describe("binStart", () => { // A Wednesday, late in a quarter, in the dive's own wall clock. const time = Date.UTC(2025, 8, 17, 14, 30); it("aligns to the calendar in the wall-clock day", () => { expect(binStart(time, "day")).toBe(Date.UTC(2025, 8, 17)); expect(binStart(time, "month")).toBe(Date.UTC(2025, 8, 1)); expect(binStart(time, "quarter")).toBe(Date.UTC(2025, 6, 1)); expect(binStart(time, "year")).toBe(Date.UTC(2025, 0, 1)); }); it("starts weeks on Monday", () => { expect(binStart(time, "week")).toBe(Date.UTC(2025, 8, 15)); // A Sunday belongs to the week that began six days earlier. expect(binStart(Date.UTC(2025, 8, 21), "week")).toBe(Date.UTC(2025, 8, 15)); expect(binStart(Date.UTC(2025, 8, 22), "week")).toBe(Date.UTC(2025, 8, 22)); }); }); describe("binSummaries", () => { const DAY = 24 * 60 * 60 * 1000; it("groups dives by calendar bin and leaves empty bins out", () => { const times = [ Date.UTC(2025, 1, 10, 9), Date.UTC(2025, 1, 10, 14), Date.UTC(2025, 1, 11, 9), Date.UTC(2025, 3, 2, 9), ]; const bins = binSummaries(times, [10, 12, 14, 20], "week"); expect(bins.map((bin) => bin.count)).toEqual([3, 1]); expect(bins[0]).toMatchObject({ start: Date.UTC(2025, 1, 10), end: Date.UTC(2025, 1, 17), first: times[0], last: times[2], median: 12, }); expect(bins[1].start).toBe(Date.UTC(2025, 2, 31)); }); it("summarizes a bin by its quartiles, not its mean", () => { const [bin] = binSummaries( [0, 1, 2, 3, 4].map((hour) => Date.UTC(2025, 5, 3, hour)), [11, 12, 13, 14, 40], "day", ); expect(bin).toMatchObject({ q1: 12, median: 13, q3: 14 }); }); it("splits a trip that straddles two bins", () => { const times = [0, 1, 2, 3].map((day) => Date.UTC(2025, 0, 30) + day * DAY); expect(binSummaries(times, [1, 2, 3, 4], "month")).toHaveLength(2); }); it("is empty for an empty series", () => { expect(binSummaries([], [], "day")).toEqual([]); }); });