mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Every entry in the display-transform registry declares the decimals its unit is read at. That number was carried to the render layer and read by exactly one screen out of all the ones that show a converted value, so everywhere else the raw product of a kilogram and 2.20462262185 reached the surface. Pounds came out with a dozen digits it never needed. Move the rounding into `applyDisplayTransform` and `applyDisplayTransformDelta`, so a surface gets a correctly-scaled number whether or not it remembers to ask. Patching the weight cell would have left the same trap behind inches, Fahrenheit, miles per hour and miles. Two boundaries stay exempt and are documented as such. A transform that does not rescale is the exact identity and is never rounded, so a stored 78.45 kg keeps rendering as 78.45 on the metric branch and every untransformed metric is byte-identical. `invertDisplayTransform` — the entry boundary — keeps full precision, because a value rounded on the way out and inverted on the way back would drift the stored canonical number a little further on every save; the entry surfaces quantise to their own two-decimal canonical dialect instead. `applyDisplayTransformUnrounded` is the one documented escape hatch, for arithmetic whose result is not rendered. The target adapter needs it: its guardrails round inward, and rounding the 30 kg floor to the nearest tenth of a pound first gives an edge that inverts to 29.98 kg and the server rejects it. The weight page and the HealthKit scaffold were folding the factor into their stat strips by hand. Both now convert through the helper, so the raw multiplier is only ever handed to the chart, which owns its own series and formatting. Formatting is unchanged. Precision is fixed at the conversion and the surfaces keep the formatter they already used, so switching preference changes the unit and nothing else about how the number looks. Refs #627
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
applyDisplayTransform,
|
|
applyDisplayTransformDelta,
|
|
applyDisplayTransformUnrounded,
|
|
invertDisplayTransform,
|
|
getDisplayTransform,
|
|
hasDisplayTransform,
|
|
transformRescales,
|
|
TRANSFORMED_TYPES,
|
|
DEFAULT_UNIT_PREFERENCE,
|
|
} from "../display-transform";
|
|
|
|
describe("display-transform", () => {
|
|
it("converts WALKING_SPEED m/s → km/h (factor 3.6)", () => {
|
|
const t = getDisplayTransform("WALKING_SPEED", "metric");
|
|
expect(t.factor).toBe(3.6);
|
|
expect(t.displayUnit).toBe("km/h");
|
|
expect(t.decimals).toBe(1);
|
|
// 1.3 m/s → 4.68 km/h, rendered at the declared single decimal.
|
|
expect(applyDisplayTransform(1.3, t)).toBe(4.7);
|
|
expect(applyDisplayTransformUnrounded(1.3, t)).toBeCloseTo(4.68, 5);
|
|
});
|
|
|
|
it("converts WALKING_RUNNING_DISTANCE m → km (factor 0.001)", () => {
|
|
const t = getDisplayTransform("WALKING_RUNNING_DISTANCE", "metric");
|
|
expect(t.factor).toBe(0.001);
|
|
expect(t.displayUnit).toBe("km");
|
|
expect(t.decimals).toBe(2);
|
|
// 5000 m → 5.00 km.
|
|
expect(applyDisplayTransform(5000, t)).toBeCloseTo(5, 5);
|
|
});
|
|
|
|
it("exposes imperial branches for speed + distance", () => {
|
|
const speed = getDisplayTransform("WALKING_SPEED", "imperial");
|
|
expect(speed.displayUnit).toBe("mph");
|
|
// 1.34 m/s ≈ 2.9975 mph → 3 mph at the declared single decimal.
|
|
expect(applyDisplayTransform(1.34, speed)).toBe(3);
|
|
expect(applyDisplayTransformUnrounded(1.34, speed)).toBeCloseTo(2.9975, 3);
|
|
|
|
const dist = getDisplayTransform("WALKING_RUNNING_DISTANCE", "imperial");
|
|
expect(dist.displayUnit).toBe("mi");
|
|
// 1609.34 m ≈ 1 mile, at the two decimals a distance is read at.
|
|
expect(applyDisplayTransform(1609.344, dist)).toBe(1);
|
|
});
|
|
|
|
it("returns identity (factor 1) for untransformed types", () => {
|
|
for (const type of [
|
|
"PULSE",
|
|
"WEIGHT",
|
|
"RESPIRATORY_RATE",
|
|
"AUDIO_EXPOSURE_ENV",
|
|
]) {
|
|
const t = getDisplayTransform(type);
|
|
expect(t.factor).toBe(1);
|
|
// identity scale leaves the raw value untouched
|
|
expect(applyDisplayTransform(42, t)).toBe(42);
|
|
}
|
|
});
|
|
|
|
it("identity transform surfaces the canonical unit", () => {
|
|
expect(getDisplayTransform("WEIGHT").displayUnit).toBe("kg");
|
|
expect(getDisplayTransform("PULSE").displayUnit).toBe("bpm");
|
|
expect(getDisplayTransform("WALKING_STEP_LENGTH").displayUnit).toBe("m");
|
|
});
|
|
|
|
it("defaults to the metric preference", () => {
|
|
expect(DEFAULT_UNIT_PREFERENCE).toBe("metric");
|
|
expect(getDisplayTransform("WALKING_SPEED")).toEqual(
|
|
getDisplayTransform("WALKING_SPEED", "metric"),
|
|
);
|
|
});
|
|
|
|
// ── v1.32.26 — weight-class / length / temperature registry ──
|
|
|
|
it("converts WEIGHT kg → lb on the imperial branch", () => {
|
|
const metric = getDisplayTransform("WEIGHT", "metric");
|
|
expect(metric.factor).toBe(1);
|
|
expect(metric.displayUnit).toBe("kg");
|
|
// Metric is the identity on the number — byte-identical to storage.
|
|
expect(applyDisplayTransform(95.25, metric)).toBe(95.25);
|
|
|
|
const imperial = getDisplayTransform("WEIGHT", "imperial");
|
|
expect(imperial.displayUnit).toBe("lb");
|
|
// 95.25 kg → 210.0 lb.
|
|
expect(applyDisplayTransform(95.25, imperial)).toBeCloseTo(210.0, 1);
|
|
});
|
|
|
|
it("shares the mass branch across every body-mass metric", () => {
|
|
for (const type of [
|
|
"TOTAL_BODY_WATER",
|
|
"BONE_MASS",
|
|
"FAT_MASS",
|
|
"FAT_FREE_MASS",
|
|
"MUSCLE_MASS",
|
|
"LEAN_BODY_MASS",
|
|
"GRIP_STRENGTH",
|
|
]) {
|
|
expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb");
|
|
expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg");
|
|
// 10 kg → 22.0462… lb → 22 lb at the pound's single decimal.
|
|
expect(
|
|
applyDisplayTransform(10, getDisplayTransform(type, "imperial")),
|
|
).toBe(22);
|
|
}
|
|
});
|
|
|
|
it("converts WAIST_CIRCUMFERENCE cm → in", () => {
|
|
const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial");
|
|
expect(imperial.displayUnit).toBe("in");
|
|
// 84 cm → 33.0708… in → 33.1 in at the inch's single decimal.
|
|
expect(applyDisplayTransform(84, imperial)).toBe(33.1);
|
|
});
|
|
|
|
it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => {
|
|
const metric = getDisplayTransform("BODY_TEMPERATURE", "metric");
|
|
expect(metric.displayUnit).toBe("°C");
|
|
expect(metric.offset ?? 0).toBe(0);
|
|
|
|
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
|
expect(imperial.displayUnit).toBe("°F");
|
|
expect(imperial.factor).toBe(1.8);
|
|
expect(imperial.offset).toBe(32);
|
|
// 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading),
|
|
// rendered at the declared single decimal.
|
|
expect(applyDisplayTransform(36.6, imperial)).toBe(97.9);
|
|
expect(applyDisplayTransformUnrounded(36.6, imperial)).toBeCloseTo(
|
|
97.88,
|
|
2,
|
|
);
|
|
// 0 °C → 32 °F (not 0).
|
|
expect(applyDisplayTransform(0, imperial)).toBe(32);
|
|
});
|
|
|
|
it("skin + wrist temperature share the affine temperature branch", () => {
|
|
for (const type of ["SKIN_TEMPERATURE", "WRIST_TEMPERATURE"]) {
|
|
const imperial = getDisplayTransform(type, "imperial");
|
|
expect(imperial.displayUnit).toBe("°F");
|
|
expect(imperial.offset).toBe(32);
|
|
}
|
|
});
|
|
|
|
it("BODY_TEMPERATURE_DEVIATION scales °C→°F WITHOUT the offset (it is a Δ)", () => {
|
|
const imperial = getDisplayTransform(
|
|
"BODY_TEMPERATURE_DEVIATION",
|
|
"imperial",
|
|
);
|
|
expect(imperial.displayUnit).toBe("°F");
|
|
expect(imperial.factor).toBe(1.8);
|
|
// A signed deviation is a delta: 1 Δ°C is a 1.8 Δ°F, NEVER 33.8 °F.
|
|
expect(imperial.offset ?? 0).toBe(0);
|
|
expect(applyDisplayTransform(1, imperial)).toBeCloseTo(1.8, 5);
|
|
});
|
|
|
|
it("the delta helper NEVER applies the affine offset", () => {
|
|
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
|
// Same absolute transform (offset 32), but a delta must be factor-only.
|
|
expect(applyDisplayTransform(1, imperial)).toBe(33.8);
|
|
expect(applyDisplayTransformDelta(1, imperial)).toBeCloseTo(1.8, 5);
|
|
// A 2 °C rise reads as a 3.6 °F rise, not a 35.6 °F reading.
|
|
expect(applyDisplayTransformDelta(2, imperial)).toBeCloseTo(3.6, 5);
|
|
});
|
|
|
|
it("inverse round-trips every registered branch (entry ⇄ display)", () => {
|
|
// The entry boundary works on the UNROUNDED conversion: a display
|
|
// value the surface produced must invert back to the exact canonical
|
|
// number it came from. `applyDisplayTransform` rounds for the screen,
|
|
// so the round-trip property is stated against the raw arithmetic.
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
for (const pref of ["metric", "imperial"] as const) {
|
|
const transform = getDisplayTransform(type, pref);
|
|
for (const raw of [0, 1, 36.6, 84, 95.25, 210]) {
|
|
const shown = applyDisplayTransformUnrounded(raw, transform);
|
|
expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("hasDisplayTransform + TRANSFORMED_TYPES agree, and exclude BMI/glucose", () => {
|
|
expect(hasDisplayTransform("WEIGHT")).toBe(true);
|
|
expect(hasDisplayTransform("BODY_TEMPERATURE")).toBe(true);
|
|
expect(hasDisplayTransform("PULSE")).toBe(false);
|
|
// Deliberately excluded (own preference / universal unit).
|
|
expect(TRANSFORMED_TYPES.has("BLOOD_GLUCOSE")).toBe(false);
|
|
expect(TRANSFORMED_TYPES.has("BODY_MASS_INDEX")).toBe(false);
|
|
// Every registered type resolves a non-identity or symbol-only metric
|
|
// branch, and both branches exist.
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
expect(getDisplayTransform(type, "metric").displayUnit).toBeTruthy();
|
|
expect(getDisplayTransform(type, "imperial").displayUnit).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.32.39 — issue #627 follow-up. A conversion declares the decimals its
|
|
* unit is read at; before this the number was carried to the display layer
|
|
* and read by one screen out of all the ones that render a converted value,
|
|
* so everywhere else the raw product of a kilogram and 2.20462262185 went
|
|
* straight to the surface.
|
|
*/
|
|
describe("display-transform — converted values carry the declared precision", () => {
|
|
it("renders the reported kilogram reading at one decimal, not seventeen", () => {
|
|
// The reporter's case: a stored weight whose pound conversion is a long
|
|
// float. 83.99981845627636 kg * 2.20462262185 is 185.1879000… lb.
|
|
const imperial = getDisplayTransform("WEIGHT", "imperial");
|
|
const raw = 83.99981845627636;
|
|
expect(String(applyDisplayTransformUnrounded(raw, imperial))).toMatch(
|
|
/^185\.1879/,
|
|
);
|
|
expect(applyDisplayTransform(raw, imperial)).toBe(185.2);
|
|
expect(String(applyDisplayTransform(raw, imperial))).toBe("185.2");
|
|
});
|
|
|
|
it("rounds every converted unit family to its own declared decimals", () => {
|
|
const cases: Array<[string, number, number]> = [
|
|
// mass: kg → lb, one decimal
|
|
["WEIGHT", 83.99981845627636, 185.2],
|
|
// length: cm → in, one decimal
|
|
["WAIST_CIRCUMFERENCE", 84, 33.1],
|
|
// temperature: °C → °F, affine (the offset must survive rounding)
|
|
["BODY_TEMPERATURE", 36.66666, 98],
|
|
// speed: m/s → mph, one decimal
|
|
["WALKING_SPEED", 1.3333333, 2.98],
|
|
// distance: m → mi, two decimals
|
|
["WALKING_RUNNING_DISTANCE", 8046.72, 5],
|
|
];
|
|
for (const [type, raw, expected] of cases) {
|
|
const transform = getDisplayTransform(type, "imperial");
|
|
const shown = applyDisplayTransform(raw, transform);
|
|
// Re-rounding at the declared grain is a no-op: the value carries no
|
|
// more precision than the transform says it should.
|
|
const scale = 10 ** transform.decimals;
|
|
expect(Math.round(shown * scale) / scale).toBe(shown);
|
|
expect(shown).toBeCloseTo(expected, transform.decimals);
|
|
}
|
|
});
|
|
|
|
it("keeps the affine offset exact through the rounding", () => {
|
|
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
|
// 0 °C is 32 °F on the nose; rounding must not shift a clean value.
|
|
expect(applyDisplayTransform(0, imperial)).toBe(32);
|
|
expect(applyDisplayTransform(37, imperial)).toBe(98.6);
|
|
// 36.66666 °C = 97.999988 °F → 98.0, not 97.999988.
|
|
expect(applyDisplayTransform(36.66666, imperial)).toBe(98);
|
|
});
|
|
|
|
it("rounds a converted DELTA to the same grain, offset-free", () => {
|
|
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
|
// A 0.37 °C rise is a 0.666 °F rise → 0.7, and never picks up the +32.
|
|
expect(applyDisplayTransformDelta(0.37, imperial)).toBe(0.7);
|
|
const mass = getDisplayTransform("WEIGHT", "imperial");
|
|
expect(applyDisplayTransformDelta(-0.5, mass)).toBe(-1.1);
|
|
});
|
|
|
|
it("leaves a metric-preference value completely untouched", () => {
|
|
// The metric branch of every registered type is the identity on the
|
|
// number — it only relabels the symbol. A stored 78.45 kg is not
|
|
// conversion noise and must not be rounded away to 78.5.
|
|
for (const type of TRANSFORMED_TYPES) {
|
|
const metric = getDisplayTransform(type, "metric");
|
|
if (transformRescales(metric)) continue;
|
|
for (const raw of [78.45, 0.123456789, 95.25, 36.66666]) {
|
|
expect(applyDisplayTransform(raw, metric)).toBe(raw);
|
|
expect(applyDisplayTransformDelta(raw, metric)).toBe(raw);
|
|
}
|
|
}
|
|
// Same for a type with no transform at all.
|
|
const identity = getDisplayTransform("PULSE");
|
|
expect(applyDisplayTransform(58.123456789, identity)).toBe(58.123456789);
|
|
expect(applyDisplayTransformDelta(58.123456789, identity)).toBe(
|
|
58.123456789,
|
|
);
|
|
});
|
|
|
|
it("keeps the entry path free of drift on a typed imperial value", () => {
|
|
// What the entry surfaces actually do: invert the typed display number
|
|
// (full precision, no rounding), then quantise to the two-decimal
|
|
// canonical dialect. Reopening must show the number that was typed.
|
|
const imperial = getDisplayTransform("WEIGHT", "imperial");
|
|
for (let typed = 70; typed <= 660; typed += 0.1) {
|
|
const shown = Math.round(typed * 10) / 10;
|
|
const inverted = invertDisplayTransform(shown, imperial);
|
|
const canonical = Math.round(inverted * 100) / 100;
|
|
expect(applyDisplayTransform(canonical, imperial)).toBe(shown);
|
|
}
|
|
});
|
|
|
|
it("keeps the entry path free of drift on a typed Fahrenheit value", () => {
|
|
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
|
|
for (let typed = 95; typed <= 106; typed += 0.1) {
|
|
const shown = Math.round(typed * 10) / 10;
|
|
const canonical =
|
|
Math.round(invertDisplayTransform(shown, imperial) * 100) / 100;
|
|
expect(applyDisplayTransform(canonical, imperial)).toBe(shown);
|
|
}
|
|
});
|
|
|
|
it("never rounds the inverse — the write path keeps full precision", () => {
|
|
const imperial = getDisplayTransform("WEIGHT", "imperial");
|
|
// 185.2 lb is 84.00825… kg. Quantising here instead of at the entry
|
|
// surface would push the stored value a little further on every save.
|
|
expect(invertDisplayTransform(185.2, imperial)).toBe(
|
|
185.2 / imperial.factor,
|
|
);
|
|
expect(invertDisplayTransform(185.2, imperial)).not.toBe(84);
|
|
});
|
|
});
|