import { describe, expect, it } from "vitest"; import { ENTRY_DIMENSIONS, FEET_PER_MILE, METERS_PER_NAUTICAL_MILE, formatWindSpeed, KG_PER_POUND, LITERS_PER_CUBIC_FOOT, METERS_PER_FOOT, PSI_PER_BAR, UNIT_SYSTEMS, cylinderCubicFeet, displayBound, displayNumber, formatAltitude, formatComparableDepth, formatDepth, formatGasVolume, formatPressure, formatRmv, formatSac, formatTemperature, formatVisibility, formatWeight, isIntegerDimension, roundTo, toCommittedMetric, toDisplayUnits, toMetricUnits, unitLabel, unitWord, withoutFloatNoise, type EntryDimension, } from "./units"; describe("the constants", () => { // Exact definitions, not rounded figures. A test on each because these are the // one thing in the module a reader cannot check by reasoning about the code - // they have to be checked against a standard, and this is where that happens. it("are the exact definitions", () => { expect(METERS_PER_FOOT).toBe(0.3048); expect(KG_PER_POUND).toBe(0.45359237); // A cubic foot is 0.3048³ m³, to the last digit. expect(LITERS_PER_CUBIC_FOOT).toBeCloseTo(0.3048 ** 3 * 1000, 9); expect(FEET_PER_MILE).toBe(5280); expect(METERS_PER_NAUTICAL_MILE).toBe(1852); expect(PSI_PER_BAR).toBeCloseTo(14.503773773020923, 12); }); it("lists both systems and nothing else", () => { expect(UNIT_SYSTEMS).toEqual(["metric", "imperial"]); }); }); describe("roundTo", () => { it("rounds to the requested places", () => { expect(roundTo(23.888888888888889, 2)).toBe(23.89); expect(roundTo(29.8704, 2)).toBe(29.87); expect(roundTo(372.4, 0)).toBe(372); }); // The corpus's float noise, which is the reason display goes through this at all. it("collapses float noise", () => { expect(roundTo(28.000000000000004, 2)).toBe(28); }); }); describe("withoutFloatNoise", () => { // Neither is `Math.fround` of its decimal: both sit an ulp or so off it. it("drops an exporter's single-precision noise", () => { expect(withoutFloatNoise(21.8000011)).toBe("21.8"); expect(withoutFloatNoise(2.70000029)).toBe("2.7"); expect(withoutFloatNoise(9.8000011)).toBe("9.8"); }); it("drops double-precision noise", () => { expect(withoutFloatNoise(0.1 + 0.2)).toBe("0.3"); expect(withoutFloatNoise(28.000000000000004)).toBe("28"); }); it("keeps the decimals a value was genuinely recorded at", () => { expect(withoutFloatNoise(21.815)).toBe("21.815"); expect(withoutFloatNoise(18.288)).toBe("18.288"); expect(withoutFloatNoise(-1.5)).toBe("-1.5"); }); it("leaves whole numbers whole", () => { expect(withoutFloatNoise(0)).toBe("0"); expect(withoutFloatNoise(30)).toBe("30"); expect(withoutFloatNoise(20000001)).toBe("20000001"); }); }); describe("metric display", () => { // Two decimals is what the API records, so a value it stored renders unchanged. it("renders a recorded value exactly as stored", () => { expect(formatDepth(30.52, "metric")).toBe("30.52 m"); expect(formatPressure(206.84, "metric")).toBe("206.84 bar"); expect(formatWeight(6.5, "metric")).toBe("6.5 kg"); }); // The two deliberate changes: an imported value carrying more decimals than the // API would have stored, and the corpus's float noise. it("trims to the precision the API records", () => { expect(formatDepth(18.288, "metric")).toBe("18.29 m"); expect(formatDepth(28.000000000000004, "metric")).toBe("28 m"); }); it("drops trailing zeros", () => { expect(formatDepth(30.5, "metric")).toBe("30.5 m"); expect(formatDepth(30, "metric")).toBe("30 m"); expect(formatDepth(30.0, "metric")).toBe("30 m"); }); // Degrees attach, everything else takes a space. it("attaches degrees and spaces everything else", () => { expect(formatTemperature(23.89, "metric")).toBe("23.89°C"); expect(formatVisibility(15, "metric")).toBe("15 m"); }); it("keeps the integer dimensions whole", () => { expect(formatVisibility(15, "metric")).toBe("15 m"); expect(formatAltitude(372, "metric")).toBe("372 m"); }); it("renders the derived gas figures at the API's precision", () => { expect(formatSac(0.82, "metric")).toBe("0.82 bar/min"); expect(formatRmv(18.24, "metric")).toBe("18.24 L/min"); expect(formatGasVolume(3618, "metric")).toBe("3618 L"); }); }); describe("imperial display", () => { it("renders the scalars whole", () => { expect(formatDepth(30.48, "imperial")).toBe("100 ft"); expect(formatTemperature(23.89, "imperial")).toBe("75°F"); expect(formatWeight(6, "imperial")).toBe("13 lb"); expect(formatPressure(206.84, "imperial")).toBe("3000 psi"); expect(formatVisibility(15, "imperial")).toBe("49 ft"); expect(formatAltitude(372, "imperial")).toBe("1220 ft"); }); // The rates keep decimals where the scalars don't: a whole psi/min is one // significant figure, and a cubic foot is 28 litres. it("keeps a decimal on the rates", () => { expect(formatSac(0.82, "imperial")).toBe("11.9 psi/min"); expect(formatRmv(18.24, "imperial")).toBe("0.64 cuft/min"); expect(formatGasVolume(3618, "imperial")).toBe("127.8 cuft"); }); it("handles temperatures below freezing", () => { expect(formatTemperature(0, "imperial")).toBe("32°F"); expect(formatTemperature(-50, "imperial")).toBe("-58°F"); }); it("handles an altitude below sea level", () => { expect(formatAltitude(-430, "imperial")).toBe("-1411 ft"); }); }); describe("the metric decimals override", () => { // The Home page's Recent Dives row wants a whole metre; the END and EAD strings // want one decimal. it("fixes the metric decimals", () => { expect(formatDepth(30.52, "metric", { decimals: 0 })).toBe("31 m"); expect(formatDepth(30, "metric", { decimals: 1 })).toBe("30.0 m"); expect(formatRmv(18.24, "metric", { decimals: 1 })).toBe("18.2 L/min"); }); // Deliberate: a whole foot is already finer than a tenth of a metre, so there is // nothing left to coarsen on the imperial side. it("leaves imperial alone", () => { expect(formatDepth(30.48, "imperial", { decimals: 1 })).toBe("100 ft"); expect(formatRmv(18.24, "imperial", { decimals: 1 })).toBe("0.64 cuft/min"); }); }); describe("formatComparableDepth", () => { it("writes metric at the two decimals the API stores", () => { expect(formatComparableDepth(33.75, "metric")).toBe("33.75 m"); expect(formatComparableDepth(40, "metric")).toBe("40 m"); }); // Where `formatDepth` writes whole feet: a comparison needs to tell 40.02 m and // 40 m apart, and both of those are 131 ft. it("writes imperial to a tenth of a foot", () => { expect(formatComparableDepth(40.02, "imperial")).toBe("131.3 ft"); expect(formatComparableDepth(40, "imperial")).toBe("131.2 ft"); }); it("rounds a ceiling down rather than to nearest", () => { expect(formatComparableDepth(15.925925925925927, "metric")).toBe("15.93 m"); expect( formatComparableDepth(15.925925925925927, "metric", { floor: true }), ).toBe("15.92 m"); }); // Flooring the raw quotient would answer 39.99, turning a division's last bit // into a centimetre of margin. it("settles float noise before flooring", () => { expect( formatComparableDepth(39.999999999999993, "metric", { floor: true }), ).toBe("40 m"); }); // The scaling is noisy in its own right: 9.2 * 100 is 919.9999999999999, and // EAN62.5 planned to ppO₂ 1.2 - both values the form offers - lands there. it("keeps a limit that is already on the grid", () => { expect(formatComparableDepth(9.2, "metric", { floor: true })).toBe("9.2 m"); expect( formatComparableDepth(18.49002849002849, "metric", { floor: true }), ).toBe("18.49 m"); }); // The other direction, and the one that matters more: a limit that floors *up* // names a depth past itself, which is what the floor exists to prevent. it("never floors a value above itself", () => { expect( formatComparableDepth(39.46996466431094, "metric", { floor: true }), ).toBe("39.46 m"); }); }); describe("labels", () => { it("names the short unit per system", () => { expect(unitLabel("depth", "metric")).toBe("m"); expect(unitLabel("depth", "imperial")).toBe("ft"); expect(unitLabel("temperature", "imperial")).toBe("°F"); expect(unitLabel("pressure", "imperial")).toBe("psi"); expect(unitLabel("weight", "imperial")).toBe("lb"); expect(unitLabel("rmv", "imperial")).toBe("cuft/min"); expect(unitLabel("sac", "imperial")).toBe("psi/min"); }); // Spoken forms, for the chart descriptions: "ft" is read as a word and "°C" is // skipped entirely. it("spells the unit out for a screen reader", () => { expect(unitWord("depth", "imperial")).toBe("feet"); expect(unitWord("temperature", "imperial")).toBe("degrees Fahrenheit"); expect(unitWord("temperature", "metric")).toBe("degrees Celsius"); expect(unitWord("pressure", "imperial")).toBe("psi"); expect(unitWord("rmv", "imperial")).toBe("cubic feet per minute"); expect(unitWord("rmv", "metric")).toBe("liters per minute"); }); }); describe("conversion in and out", () => { it("passes metric through untouched", () => { expect(toDisplayUnits(30.48, "depth", "metric")).toBe(30.48); expect(toMetricUnits(30.48, "depth", "metric")).toBe(30.48); expect(displayNumber(30.52, "depth", "metric")).toBe("30.52"); }); it("converts to imperial", () => { expect(toDisplayUnits(30.48, "depth", "imperial")).toBeCloseTo(100, 10); expect(toDisplayUnits(0, "temperature", "imperial")).toBe(32); expect(toDisplayUnits(1, "pressure", "imperial")).toBeCloseTo(14.5038, 4); }); it("knows which metric columns are whole numbers", () => { expect(isIntegerDimension("visibility")).toBe(true); expect(isIntegerDimension("altitude")).toBe(true); expect(isIntegerDimension("depth")).toBe(false); expect(isIntegerDimension("temperature")).toBe(false); }); }); describe("the entry dimensions", () => { // The list is a runtime array now, because `lib/entry-units.ts` filters stored // override keys against it and the dive form hangs a toggle off each entry. // `EntryDimension` is read back off it, so a dimension added to one and not the // other is a type error rather than a silently unreachable toggle - what this // pins is the six themselves, and that every one is a dimension the app can // actually convert and label. it("is the six a diver types into", () => { expect([...ENTRY_DIMENSIONS]).toEqual([ "depth", "temperature", "weight", "pressure", "visibility", "altitude", ]); }); it("labels and converts every one of them", () => { for (const dimension of ENTRY_DIMENSIONS) { expect(unitLabel(dimension, "metric")).toBeTruthy(); expect(unitLabel(dimension, "imperial")).toBeTruthy(); expect(unitWord(dimension, "imperial")).toBeTruthy(); expect(toCommittedMetric(1, dimension, "metric")).toBe(1); } }); // The derived figures the API computes stay out: there is no box to type an // RMV into, so a toggle for one would govern nothing. it("leaves out the figures the app only renders", () => { const entries: readonly string[] = ENTRY_DIMENSIONS; expect(entries).not.toContain("sac"); expect(entries).not.toContain("rmv"); expect(entries).not.toContain("gasVolume"); }); }); describe("entry round-trips", () => { // The guarantee the whole entry design rests on: what the diver typed is what // comes back, because the conversion is inverted before the metric value is // rounded. Every whole imperial value in each dimension's plausible range. const cases: Array<{ dimension: EntryDimension; from: number; to: number; step?: number; }> = [ { dimension: "depth", from: 0, to: 500 }, { dimension: "temperature", from: -58, to: 122 }, { dimension: "weight", from: 0, to: 100 }, { dimension: "pressure", from: 0, to: 5000, step: 10 }, ]; for (const { dimension, from, to, step = 1 } of cases) { it(`round-trips every whole imperial ${dimension}`, () => { const broken: number[] = []; for (let typed = from; typed <= to; typed += step) { const metric = toCommittedMetric(typed, dimension, "imperial"); if (Number(displayNumber(metric, dimension, "imperial")) !== typed) { broken.push(typed); } } expect(broken).toEqual([]); }); } // The exact values the API is verified against for imperial round-tripping. it("commits the documented metric values", () => { expect(toCommittedMetric(100, "depth", "imperial")).toBe(30.48); expect(toCommittedMetric(75, "temperature", "imperial")).toBe(23.89); expect(toCommittedMetric(3000, "pressure", "imperial")).toBe(206.84); expect(toCommittedMetric(33, "visibility", "imperial")).toBe(10); expect(toCommittedMetric(1220, "altitude", "imperial")).toBe(372); }); // Metric entry commits at the API's own precision. This is the one deliberate // change on the metric side, and it is invisible for `step`-compliant input - // it generalizes what the temperature field already did to its float siblings. it("rounds metric entry to two decimals", () => { expect(toCommittedMetric(23.888888888888889, "temperature", "metric")).toBe( 23.89, ); expect(toCommittedMetric(30.52, "depth", "metric")).toBe(30.52); }); it("commits whole metres for the integer dimensions", () => { expect(toCommittedMetric(15.4, "visibility", "metric")).toBe(15); expect(toCommittedMetric(372.6, "altitude", "metric")).toBe(373); }); // The accepted loss, pinned so it cannot drift into something worse: an // `Integer` column cannot hold the metres 50 ft is, so it re-renders as 49 ft. // Widening the column to a Float was considered and rejected - whole-metre // resolution is the recorded entry convention, and visibility is an estimate. it("loses a foot of visibility to the integer column", () => { const metric = toCommittedMetric(50, "visibility", "imperial"); expect(metric).toBe(15); expect(formatVisibility(metric, "imperial")).toBe("49 ft"); }); // A common altitude that does survive, which is why the loss above is tolerable. it("round-trips a common altitude", () => { const metric = toCommittedMetric(1220, "altitude", "imperial"); expect(metric).toBe(372); expect(formatAltitude(metric, "imperial")).toBe("1220 ft"); }); }); describe("displayBound", () => { // Rounded inward, so the browser's spinner can never offer a value the metric // bound behind it rejects. it("converts a bound inward", () => { expect(displayBound(-450, "altitude", "imperial", "min")).toBe(-1476); expect(displayBound(6500, "altitude", "imperial", "max")).toBe(21325); expect(displayBound(-50, "temperature", "imperial", "min")).toBe(-58); expect(displayBound(50, "temperature", "imperial", "max")).toBe(122); }); it("keeps a converted bound inside the metric one", () => { expect( toCommittedMetric( displayBound(-450, "altitude", "imperial", "min"), "altitude", "imperial", ), ).toBeGreaterThanOrEqual(-450); expect( toCommittedMetric( displayBound(6500, "altitude", "imperial", "max"), "altitude", "imperial", ), ).toBeLessThanOrEqual(6500); }); it("passes a metric bound through untouched", () => { expect(displayBound(-450, "altitude", "metric", "min")).toBe(-450); expect(displayBound(0.5, "depth", "metric", "min")).toBe(0.5); }); }); describe("cylinderCubicFeet", () => { it("names a cylinder by its gas at the working pressure, as Subsurface does", () => { // Catalina's S80: 11.1 L at 3000 psi. expect(cylinderCubicFeet(11.1, 207)).toBe(80); // A 12 L 232 bar steel. expect(cylinderCubicFeet(12, 232)).toBe(97); }); }); describe("formatWindSpeed", () => { // The API stores m/s; nobody reads a wind in it. it("shows metres per second as whole knots", () => { expect(formatWindSpeed(5.9)).toBe("11 kn"); expect(formatWindSpeed(0)).toBe("0 kn"); }); });