Ctrl K
ComunidadGuías…

src/lib/units.test.ts

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

Archivo original ↗Preguntar a KiBot
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");
  });
});