Ctrl K
ComunidadGuías…

src/lib/dive-gas.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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> = {}): DiveMixture {
  return {
    id: 1,
    volume: 12,
    start_pressure: 200,
    end_pressure: 50,
    oxygen: 21,
    helium: 0,
    ...overrides,
  };
}

const dive = (overrides: Partial<Dive> = {}) =>
  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> = {}): 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> = {},
): 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<Dive>).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<DiveMixture> = {},
    second: Partial<DiveMixture> = {},
  ): 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 `<tankdata>` with a gas link and no
    // `<tankvolume>`, 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([]);
  });
});