Ctrl K
ComunidadGuías…

src/lib/dive-mixtures.test.ts

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

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from "vitest";
import {
  PPO2_DECO,
  PPO2_WORKING,
  diveModWarning,
  ead,
  endDepth,
  gasHintParts,
  ppO2Limit,
  gasName,
  tankGas,
  mod,
  modWarning,
  ppO2AtDepth,
} from "./dive-mixtures";

describe("tankGas", () => {
  it("marks each gas the way its name reads", () => {
    expect(tankGas(20.99, 0)).toBe("air");
    expect(tankGas(32, 0)).toBe("nitrox");
    expect(tankGas(99.5, 0)).toBe("oxygen");
    expect(tankGas(21, 35)).toBe("trimix");
  });

  it("draws a gas it cannot name plain, as air", () => {
    // Unrecorded, and impossible: neither says what was breathed.
    expect(tankGas(null, null)).toBe("air");
    expect(tankGas(32, null)).toBe("air");
    expect(tankGas(50, 60)).toBe("air");
  });
});

describe("gasName", () => {
  it("names the three gases that have their own word", () => {
    expect(gasName(21, 0)).toBe("Air");
    expect(gasName(100, 0)).toBe("Oxygen");
    expect(gasName(32, 0)).toBe("EAN32");
  });

  it("treats the spread of fractions devices record for air as air", () => {
    // A real analyzer reads 20.9; Suunto exports 21; the API rounds parsed
    // fractions to 2 decimals, so 20.99 reaches the UI intact.
    expect(gasName(20.9, 0)).toBe("Air");
    expect(gasName(20.99, 0)).toBe("Air");
    expect(gasName(21.0, 0)).toBe("Air");
  });

  it("still names a deliberately enriched mix just above air as nitrox", () => {
    expect(gasName(22, 0)).toBe("EAN22");
  });

  it("names trimix as the O2/He pair divers write on the cylinder", () => {
    expect(gasName(21, 35)).toBe("21/35");
    expect(gasName(18, 45)).toBe("18/45");
    expect(gasName(10, 70)).toBe("10/70");
  });

  it("rounds to whole percent, since the shorthand is integer shorthand", () => {
    // The tank cards print the unrounded fractions under the name, so the label
    // rounding never hides the real number.
    expect(gasName(32.4, 0)).toBe("EAN32");
    expect(gasName(20.99, 35.01)).toBe("21/35");
  });

  it("reads a near-100% fill as pure oxygen", () => {
    expect(gasName(99.6, 0)).toBe("Oxygen");
  });

  it("returns null when either fraction is unrecorded", () => {
    // A parsed preview reports what the file held and null for what it didn't.
    // Helium alone being unknown is enough: air and a helium mix are then
    // indistinguishable, and no honest label covers both.
    expect(gasName(null, 0)).toBeNull();
    expect(gasName(undefined, 0)).toBeNull();
    expect(gasName(32, null)).toBeNull();
    expect(gasName(null, null)).toBeNull();
  });

  it("returns null for a non-finite fraction rather than printing NaN", () => {
    // The fallback branch formats its input straight into user-facing text, so a
    // NaN has to be turned away before it gets there.
    expect(gasName(NaN, 0)).toBeNull();
    expect(gasName(21, NaN)).toBeNull();
  });

  it("refuses to give an impossible mix a plausible-looking name", () => {
    // 50/60 passes no CHECK and no refine, but does reach a parsed preview.
    expect(gasName(50, 60)).toBe("O₂ 50% / He 60%");
    expect(gasName(0, 0)).toBe("O₂ 0% / He 0%");
    expect(gasName(-5, 0)).toBe("O₂ -5% / He 0%");
  });

  it("names a mix summing to exactly 100 as normal", () => {
    expect(gasName(30, 70)).toBe("30/70");
  });
});

describe("mod", () => {
  it("computes the working limit at the default 1.4 bar", () => {
    // 1.4 / 0.32 = 4.375 bar ambient -> 3.375 bar of water -> 33.75 m
    expect(mod(32)).toBeCloseTo(33.75, 5);
  });

  it("takes the deco ceiling as an explicit parameter", () => {
    // 1.6 / 0.32 = 5 bar -> 40 m, the number on every EAN32 deco table.
    expect(mod(32, PPO2_DECO)).toBeCloseTo(40, 5);
  });

  it("puts air's working limit where recreational depth limits sit", () => {
    expect(mod(21, PPO2_WORKING)).toBeCloseTo(56.6667, 3);
  });

  it("puts pure oxygen at its familiar 6 m deco depth", () => {
    expect(mod(100, PPO2_DECO)).toBeCloseTo(6, 5);
  });

  it("returns null rather than infinity for an oxygen-free mix", () => {
    expect(mod(0)).toBeNull();
    expect(mod(-1)).toBeNull();
  });

  it("returns null for an unrecorded or half-typed fraction", () => {
    expect(mod(null)).toBeNull();
    expect(mod(undefined)).toBeNull();
    expect(mod(NaN)).toBeNull();
  });

  it("returns null for a mix already past its limit at the surface", () => {
    // Reachable because `po2_limit` is diver-editable across [0.4, 2.0]: at 1.4 or
    // 1.6 this would need oxygen above 140 %. Pins that EAN50 planned to 0.4 does
    // not render "-2.0 m" on the mixtures card or in the form hint.
    expect(mod(50, 0.4)).toBeNull();
    expect(mod(100, 0.4)).toBeNull();
  });

  it("still answers 0 m for a mix that is exactly at its limit at the surface", () => {
    // The boundary is a real answer, not the failure above: 0.4 / 0.4 = 1 bar
    // ambient, which is the surface. Only *below* zero is meaningless.
    expect(mod(40, 0.4)).toBeCloseTo(0, 5);
  });
});

describe("ppO2AtDepth", () => {
  it("gives the surface partial pressure at zero depth", () => {
    expect(ppO2AtDepth(0, 21)).toBeCloseTo(0.21, 5);
  });

  it("scales with ambient pressure", () => {
    // 30 m = 4 bar ambient; 4 x 0.32 = 1.28
    expect(ppO2AtDepth(30, 32)).toBeCloseTo(1.28, 5);
  });

  it("agrees with mod at the depth mod returns", () => {
    const limit = mod(32, PPO2_WORKING);
    expect(limit).not.toBeNull();
    expect(ppO2AtDepth(limit as number, 32)).toBeCloseTo(PPO2_WORKING, 5);
  });

  it("returns null for an unrecorded fraction", () => {
    expect(ppO2AtDepth(30, null)).toBeNull();
  });
});

describe("endDepth", () => {
  it("counts everything that is not helium as narcotic by default", () => {
    // 21/35 at 45 m: 5.5 bar x 0.65 = 3.575 bar -> 25.75 m
    expect(endDepth(45, 35, 21)).toBeCloseTo(25.75, 5);
  });

  it("leaves a helium-free mix at its actual depth", () => {
    expect(endDepth(40, 0, 21)).toBeCloseTo(40, 5);
  });

  it("does not need the oxygen fraction in the default mode", () => {
    // Nothing but helium enters the sum when oxygen is counted narcotic, so an
    // unrecorded oxygen must not block the answer.
    expect(endDepth(45, 35, null)).toBeCloseTo(25.75, 5);
  });

  it("gives the shallower older figure when oxygen is not counted narcotic", () => {
    // 21/35 at 45 m, N2 only: 5.5 bar x 0.44 = 2.42 bar -> 14.2 m
    expect(endDepth(45, 35, 21, { o2Narcotic: false })).toBeCloseTo(14.2, 5);
  });

  it("needs the oxygen fraction when oxygen is excluded from the sum", () => {
    expect(endDepth(45, 35, null, { o2Narcotic: false })).toBeNull();
  });

  it("returns null when helium is unrecorded", () => {
    expect(endDepth(45, null, 21)).toBeNull();
  });

  it("floors at the surface rather than going negative", () => {
    // 18/45 at 4 m works out to -2.3 m: a helium mix in shallow water is less
    // narcotic than air at the surface. Reachable in the live form the moment a
    // diver has typed the "4" of a "45". Mirrors `ead`'s floor.
    expect(endDepth(4, 45, 18)).toBe(0);
    expect(endDepth(0, 70, 10)).toBe(0);
  });
});

describe("ead", () => {
  it("puts a nitrox dive shallower than it was", () => {
    // EAN32 at 30 m: 4 bar x 68/79 = 3.4430 bar -> 24.43 m
    expect(ead(30, 32)).toBeCloseTo(24.4304, 3);
  });

  it("leaves air at its actual depth", () => {
    expect(ead(30, 21)).toBeCloseTo(30, 5);
  });

  it("floors at the surface rather than going negative", () => {
    // A very rich mix in shallow water is equivalent to standing on the boat.
    expect(ead(3, 80)).toBe(0);
  });

  it("refuses a mix containing helium", () => {
    // That gas has an END, not an EAD; answering here would understate the real
    // nitrogen load.
    expect(ead(45, 21, 35)).toBeNull();
  });

  it("defaults to the helium-free case so ead(depth, o2) reads naturally", () => {
    expect(ead(30, 32)).toBe(ead(30, 32, 0));
  });

  it("returns null for an unrecorded fraction", () => {
    expect(ead(30, null)).toBeNull();
    expect(ead(30, 32, null)).toBeNull();
  });

  it("refuses a non-finite helium instead of ignoring it", () => {
    // `helium > 0` compares false for NaN, which would have computed an EAD as
    // though the cylinder held no helium at all.
    expect(ead(30, 32, NaN)).toBeNull();
  });
});

describe("modWarning", () => {
  it("stays quiet for a mix well inside its working limit", () => {
    // EAN32 works to 33.75 m.
    expect(modWarning({ oxygen: 32, helium: 0 }, 30, "metric")).toBeNull();
  });

  it("stays quiet exactly at the working limit", () => {
    expect(modWarning({ oxygen: 32, helium: 0 }, 33.75, "metric")).toBeNull();
  });

  it("stays quiet at a limit binary arithmetic lands just under", () => {
    // The gases whose 1.4 limit is a round number in decimal and a few ULPs under
    // it in floats. EAN32 above is float-exact, so only these exercise the tolerance.
    expect(modWarning({ oxygen: 28, helium: 0 }, 40, "metric")).toBeNull();
    expect(modWarning({ oxygen: 40, helium: 0 }, 25, "metric")).toBeNull();
    expect(modWarning({ oxygen: 100, helium: 0 }, 4, "metric")).toBeNull();
  });

  it("still flags the smallest breach a dive file can record", () => {
    // The tolerance is a nanometre, so the centimetre a recorded depth resolves to
    // is nowhere near it: EAN28 one centimetre past 40 m is still a breach.
    expect(modWarning({ oxygen: 28, helium: 0 }, 40.01, "metric")).toContain(
      "40 m working limit",
    );
  });

  it("repeats a number when the depth carries more than the API stores", () => {
    // The metric residual, and the only one: a parsed depth is whatever the computer
    // wrote (`max_depth` is an unrounded float all the way down), so a breach finer
    // than the centimetre the limit prints at has no room to show.
    expect(modWarning({ oxygen: 32, helium: 0 }, 33.7512, "metric")).toContain(
      "33.75 m is past this mix's 33.75 m working limit",
    );
  });

  it("names a limit the depth is genuinely past, not the depth again", () => {
    // 33.8 m is 5 cm past EAN32's 33.75 m. The limit prints at the scale it is true
    // at and rounds down, so the sentence never puts one number on both sides of
    // "is past".
    expect(modWarning({ oxygen: 32, helium: 0 }, 33.8, "metric")).toContain(
      "33.8 m is past this mix's 33.75 m working limit",
    );
  });

  it("flags a working-limit breach as a planning note, naming the working limit", () => {
    const warning = modWarning({ oxygen: 32, helium: 0 }, 36, "metric");
    expect(warning).toContain("33.75 m working limit");
    expect(warning).toContain(String(PPO2_WORKING));
    // The distinguishing half: still legal for a stop, which is why this is not
    // the louder sentence.
    expect(warning).toContain("decompression");
  });

  it("flags a deco-ceiling breach as the harder finding", () => {
    // EAN32's 1.6 ceiling is 40 m.
    const warning = modWarning({ oxygen: 32, helium: 0 }, 45, "metric");
    expect(warning).toContain("40 m limit");
    expect(warning).toContain(String(PPO2_DECO));
    expect(warning).not.toContain("decompression");
  });

  it("separates the two thresholds rather than collapsing them", () => {
    const working = modWarning({ oxygen: 32, helium: 0 }, 36, "metric");
    const deco = modWarning({ oxygen: 32, helium: 0 }, 45, "metric");
    expect(working).not.toBeNull();
    expect(deco).not.toBeNull();
    expect(working).not.toBe(deco);
  });

  it("catches a deco bottle taken to the bottom", () => {
    // EAN50 tops out at 22 m even on the 1.6 ceiling.
    expect(modWarning({ oxygen: 50, helium: 0 }, 40, "metric")).toContain(
      "22 m limit",
    );
  });

  it("says nothing when the dive records no maximum depth", () => {
    // Nothing to judge against, and inventing a depth would be worse than silence.
    expect(modWarning({ oxygen: 100, helium: 0 }, null, "metric")).toBeNull();
    expect(
      modWarning({ oxygen: 100, helium: 0 }, undefined, "metric"),
    ).toBeNull();
  });

  it("says nothing when the oxygen fraction is unusable", () => {
    expect(modWarning({ oxygen: null, helium: 0 }, 40, "metric")).toBeNull();
    expect(modWarning({ oxygen: 0, helium: 0 }, 40, "metric")).toBeNull();
  });

  it("leaves a normal trimix bottom gas alone at depth", () => {
    // 18/45 works to 67.77 m - the whole point of the mix.
    expect(modWarning({ oxygen: 18, helium: 45 }, 60, "metric")).toBeNull();
  });
});

describe("gasHintParts", () => {
  it("always leads with the gas name and its MOD", () => {
    expect(
      gasHintParts({ oxygen: 32, helium: 0, depth: null, units: "metric" }),
    ).toEqual(["EAN32", "MOD 33.75 m @ ppO₂ 1.4"]);
  });

  it("adds EAD for nitrox once a depth is known", () => {
    expect(
      gasHintParts({ oxygen: 32, helium: 0, depth: 30, units: "metric" }),
    ).toEqual(["EAN32", "MOD 33.75 m @ ppO₂ 1.4", "EAD 24.4 m at 30 m"]);
  });

  it("adds END rather than EAD for a helium mix", () => {
    const parts = gasHintParts({
      oxygen: 21,
      helium: 35,
      depth: 45,
      units: "metric",
    });
    expect(parts).toContain("END 25.8 m at 45 m (O₂ narcotic)");
    expect(parts.some((part) => part.startsWith("EAD"))).toBe(false);
  });

  it("gives air neither, since both would just restate the depth", () => {
    expect(
      gasHintParts({ oxygen: 21, helium: 0, depth: 30, units: "metric" }),
    ).toEqual(["Air", "MOD 56.66 m @ ppO₂ 1.4"]);
  });

  it("treats the whole air band as air, not just exactly 21", () => {
    // Guards the rule against `gasName`'s label: a `!== "Air"` string comparison
    // would break silently if that label changed.
    expect(
      gasHintParts({ oxygen: 20.99, helium: 0, depth: 30, units: "metric" }),
    ).toHaveLength(2);
    expect(
      gasHintParts({ oxygen: 20.9, helium: 0, depth: 30, units: "metric" }),
    ).toHaveLength(2);
  });

  it("drops the depth-dependent figures when the depth is unknown", () => {
    // What a multi-cylinder dive passes: which gas saw which depth is unknowable.
    expect(
      gasHintParts({ oxygen: 54, helium: 0, depth: null, units: "metric" }),
    ).toEqual(["EAN54", "MOD 15.92 m @ ppO₂ 1.4"]);
  });

  it("returns nothing at all while the gas is unidentifiable", () => {
    expect(
      gasHintParts({
        oxygen: undefined,
        helium: 0,
        depth: 30,
        units: "metric",
      }),
    ).toEqual([]);
    expect(
      gasHintParts({ oxygen: 32, helium: null, depth: 30, units: "metric" }),
    ).toEqual([]);
  });

  it("never lets an END run negative in shallow water", () => {
    // The floor belongs to `endDepth`, but this is the path that renders it.
    const parts = gasHintParts({
      oxygen: 18,
      helium: 45,
      depth: 4,
      units: "metric",
    });
    expect(parts).toContain("END 0.0 m at 4 m (O₂ narcotic)");
    expect(parts.some((part) => part.includes("-"))).toBe(false);
  });

  it("qualifies END with the narcosis convention it used", () => {
    // `o2Narcotic` defaults true; the older N2-only convention gives 14.2 m for
    // this gas, so the figure has to say which one it is.
    expect(
      gasHintParts({ oxygen: 21, helium: 35, depth: 45, units: "metric" }),
    ).toContain("END 25.8 m at 45 m (O₂ narcotic)");
  });

  it("names an impossible mix but claims nothing else about it", () => {
    // Not even a MOD: every figure below the name would be derived from fractions
    // that cannot coexist in one cylinder.
    expect(
      gasHintParts({ oxygen: 50, helium: 60, depth: 30, units: "metric" }),
    ).toEqual(["O₂ 50% / He 60%"]);
  });
});

describe("diveModWarning", () => {
  const AIR = { oxygen: 21, helium: 0 };
  const EAN32 = { oxygen: 32, helium: 0 };
  const EAN54 = { oxygen: 54, helium: 0 };

  it("judges a single cylinder against the dive's max depth", () => {
    // One tank was breathed throughout, so max depth is a depth this gas saw.
    expect(diveModWarning([EAN32], 45, "metric")).toContain("40 m limit");
  });

  it("keeps the working-limit note for a single cylinder", () => {
    expect(diveModWarning([EAN32], 36, "metric")).toContain("working limit");
  });

  it("stays silent on a single cylinder well within its limit", () => {
    expect(diveModWarning([EAN32], 30, "metric")).toBeNull();
  });

  it("does not blame a staged deco bottle for the dive's max depth", () => {
    // The bug this function exists to fix: air + EAN54 to 45.91 m is a normal
    // planned deco dive. EAN54 tops out at 19.62 m and was breathed on the
    // ascent, never at the bottom - warning about it fires on every correctly
    // planned technical dive.
    expect(diveModWarning([AIR, EAN54], 45.91, "metric")).toBeNull();
  });

  it("still catches a dive no gas on board could have been breathed at", () => {
    // Air is the deepest-capable of the two and tops out at 66.19 m; nothing here
    // reaches 80 m, whichever order they were breathed in.
    const warning = diveModWarning([AIR, EAN54], 80, "metric");
    expect(warning).toContain("No gas logged for this dive");
    expect(warning).toContain("66.19 m");
  });

  it("names the deepest-capable gas, not the first or the worst", () => {
    const warning = diveModWarning([EAN54, EAN32, AIR], 80, "metric");
    // Air's 66.19 m, not EAN54's 19.62 m or EAN32's 40 m.
    expect(warning).toContain("66.19 m");
  });

  it("judges a flagged parallel pair holding one gas as that gas", () => {
    // A sidemount pair breathed alternately at the same depth is one gas plan,
    // not a switch plan: there is one mix on board and it was breathed
    // throughout, so the dive's max depth is a depth it genuinely saw. Without
    // this the pair falls to the multi-cylinder rule, which only reports a depth
    // *no* gas could reach and would clear a real over-MOD dive.
    const pair = [
      { ...EAN32, usage: "parallel" as const },
      { ...EAN32, usage: "parallel" as const },
    ];

    expect(diveModWarning(pair, 45, "metric")).toContain("40 m limit");
  });

  it("keeps the working-limit note for a flagged parallel pair too", () => {
    // The half the multi-cylinder rule deliberately drops, and it is right to
    // keep here: with one gas on board, exceeding 1.4 is not the normal intended
    // state of affairs it is on a dive carrying a deco bottle.
    const pair = [
      { ...EAN32, usage: "parallel" as const },
      { ...EAN32, usage: "parallel" as const },
    ];

    expect(diveModWarning(pair, 36, "metric")).toContain("working limit");
  });

  it("needs the flag as well as the shared gas", () => {
    // Two identical cylinders with no flag are not known to have been breathed
    // together - a spare of the same mix, carried and switched to, is the same
    // two rows. The diver's answer is what makes the single-mix reading sound.
    //
    // The dive-wide sentence, not the single-mix one, and the difference is
    // visible: the flagged pair above names EAN32's own 40 m *limit*, while
    // this reports that no gas on board reaches the depth. Same number, two
    // different claims.
    const warning = diveModWarning([EAN32, EAN32], 45, "metric");
    expect(warning).toContain("No gas logged for this dive");
    expect(warning).not.toContain("working limit");
  });

  it("reads an absent helium as zero when comparing a flagged pair's gas", () => {
    // `OxygenFractions` allows `helium` absent, while the form writes a flat 0 -
    // so a pair mixing the two spellings is the same gas and has to be judged as
    // one, not dropped to the dive-wide rule on a `undefined` versus `0`
    // comparison.
    const pair = [
      { oxygen: 32, usage: "parallel" as const },
      { oxygen: 32, helium: 0, usage: "parallel" as const },
    ];

    expect(diveModWarning(pair, 45, "metric")).toContain("40 m limit");
  });

  it("needs the shared gas as well as the flag", () => {
    // A flagged pair holding *different* gases is a switch plan again, and the
    // multi-cylinder rule is what applies: EAN54 tops out at 19.62 m and was not
    // necessarily what saw 45 m.
    const mixed = [
      { ...AIR, usage: "parallel" as const },
      { ...EAN54, usage: "parallel" as const },
    ];

    expect(diveModWarning(mixed, 45.91, "metric")).toBeNull();
  });

  it("treats a half-flagged pair as the multi-cylinder set it is", () => {
    // One row Parallel, one still unset says nothing about how they were
    // breathed together, so nothing stronger than the dive-wide rule is honest.
    const half = [{ ...EAN32, usage: "parallel" as const }, EAN32];

    expect(diveModWarning(half, 45, "metric")).toContain(
      "No gas logged for this dive",
    );
  });

  it("does not apply the 1.4 working limit across several cylinders", () => {
    // 45.91 m is past air's 56.66 m working limit? No - but EAN32's is 33.75 m,
    // and a multi-cylinder dive must not report that as a problem.
    expect(diveModWarning([AIR, EAN32], 45, "metric")).toBeNull();
  });

  it("says nothing without a max depth to judge against", () => {
    expect(diveModWarning([EAN54], null, "metric")).toBeNull();
    expect(diveModWarning([AIR, EAN54], undefined, "metric")).toBeNull();
  });

  it("says nothing for a dive logging no cylinders", () => {
    expect(diveModWarning([], 40, "metric")).toBeNull();
    // Pinned at a depth that *would* warn against air: 60 m is past air's 56.66 m
    // working limit, so a form that seeded an air cylinder would warn about a
    // cylinder the page invented. Both forms can hold zero cylinders, so this guard
    // is reachable rather than theoretical.
    expect(diveModWarning([], 60, "metric")).toBeNull();
    expect(diveModWarning([AIR], 60, "metric")).toContain("working limit");
  });

  it("stays quiet at exactly the deepest gas's ceiling", () => {
    // The dive-wide branch carries the same rounding guard as `modWarning`, and
    // the boundary it guards is inclusive: a dive sitting on air's 1.6 ceiling is
    // one every gas on board reaches, not one none of them do.
    const ceiling = mod(21, PPO2_DECO);
    expect(ceiling).not.toBeNull();
    expect(diveModWarning([AIR, EAN54], ceiling!, "metric")).toBeNull();
  });

  it("says nothing when no cylinder has a usable oxygen fraction", () => {
    expect(
      diveModWarning([{ oxygen: null }, { oxygen: 0 }], 40, "metric"),
    ).toBeNull();
  });

  it("ignores an unusable cylinder when judging the rest", () => {
    // A half-typed row must not drag the deepest-capable figure around.
    expect(diveModWarning([AIR, { oxygen: null }], 80, "metric")).toContain(
      "66.19 m",
    );
  });
});

describe("ppO2Limit", () => {
  it("uses what the dive recorded", () => {
    expect(ppO2Limit({ po2_limit: 1.6 })).toBe(1.6);
  });

  it("falls back to the working limit when nothing was recorded", () => {
    expect(ppO2Limit({})).toBe(PPO2_WORKING);
    expect(ppO2Limit({ po2_limit: null })).toBe(PPO2_WORKING);
  });

  it("falls back rather than propagating a non-number", () => {
    // A MOD of NaN would render, and a MOD computed from NaN would render as an
    // empty cell that reads like "this gas has no limit".
    expect(ppO2Limit({ po2_limit: NaN })).toBe(PPO2_WORKING);
  });
});

describe("gasHintParts in imperial", () => {
  // Every depth in the hint converts; the ppO₂ beside it does not, because ppO₂ is
  // bar on every dive computer ever made whichever units it is set to.
  it("writes the MOD in feet and leaves the ppO₂ in bar", () => {
    expect(
      gasHintParts({ oxygen: 21, helium: 0, depth: null, units: "imperial" }),
    ).toEqual(["Air", "MOD 185.9 ft @ ppO₂ 1.4"]);
  });

  it("writes both depths of an EAD in feet", () => {
    expect(
      gasHintParts({ oxygen: 32, helium: 0, depth: 30, units: "imperial" }),
    ).toEqual(["EAN32", "MOD 110.7 ft @ ppO₂ 1.4", "EAD 80 ft at 98 ft"]);
  });
});

describe("the warnings in imperial", () => {
  it("keeps the two depths apart at a tenth of a foot", () => {
    // Whole feet cannot state this comparison: 40.02 m and EAN28's 40 m limit are
    // both 131 ft. A tenth is 3 cm, the nearest imperial has to what the API stores.
    expect(modWarning({ oxygen: 28, helium: 0 }, 40.02, "imperial")).toContain(
      "131.3 ft is past this mix's 131.2 ft working limit",
    );
  });

  it("still repeats a number for a breach inside a tenth of a foot", () => {
    // The residual a tenth of a foot leaves: 3 cm per step against depths recorded to
    // the centimetre. Hundredths of a foot would close it and put two digits nobody
    // analyses a cylinder to on every MOD the app shows.
    expect(modWarning({ oxygen: 21, helium: 0 }, 56.67, "imperial")).toContain(
      "185.9 ft is past this mix's 185.9 ft working limit",
    );
  });

  it("names both depths in feet", () => {
    const warning = modWarning({ oxygen: 32, helium: 0 }, 45, "imperial");

    expect(warning).toContain("147.6 ft is past this mix's 131.2 ft limit");
    expect(warning).toContain("ppO₂ 1.6");
  });
});

describe("gasHintParts with a recorded ppO2 limit", () => {
  it("computes the MOD at the dive's own limit and names it", () => {
    // EAN50 at 1.6 reaches 22 m, against 18 m at the 1.4 working limit - which is
    // the whole point of staging it.
    const parts = gasHintParts({
      oxygen: 50,
      helium: 0,
      depth: null,
      ppO2: 1.6,
      units: "metric",
    });

    expect(parts).toContain("MOD 22 m @ ppO₂ 1.6");
  });

  it("names the fallback it used when the dive recorded none", () => {
    // The label always describes the number beside it, so an absent limit still
    // says which one the MOD came from rather than leaving it unqualified.
    const parts = gasHintParts({
      oxygen: 50,
      helium: 0,
      depth: null,
      units: "metric",
    });

    expect(parts).toContain(`MOD 18 m @ ppO₂ ${PPO2_WORKING}`);
  });
});

describe("a recorded ppO2 limit does not move the warning thresholds", () => {
  it("still warns about a gas the dive recorded a high limit for", () => {
    // The tank card's MOD would show 30 m for this gas at ppO₂ 2.0, and 32 m is inside
    // it. The warning is about what the gas can physiologically take, not about
    // what the dive planned - see `diveModWarning`. A file must not be able to
    // silence it.
    const warning = diveModWarning(
      [{ oxygen: 50, po2_limit: 2.0 }],
      32,
      "metric",
    );

    expect(warning).not.toBeNull();
    expect(warning).toContain("22 m");
  });
});