test(measurements): cover the unit transform, MCP unit hardening, and a display guard

Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 00:21:36 +02:00
1 parent 13b88e47f5
commit ddc598afcb
7 files changed
+565

No files matched your search

@@ -0,0 +1,87 @@
/**
* Structural guard (a tripwire, not a proof) for the metric/imperial unit
* fix (issue #627). The dangerous failure mode is a PARTIAL fix: entry converts
* but a display surface still hardcodes the canonical unit, so an imperial user
* sees converted-then-mislabelled soup. This walks the dashboard + insight page
* sources and asserts none of them passes a transformed type's DISPLAY UNIT
* (kg / lb / cm / in / °C / °F / km/h / mph / km / mi) or a hardcoded value
* scale as a literal prop — those must resolve from the user's preference
* through `useUnitDisplay()` instead.
*
* Mutation check: add `unit="kg"` back to any swept page and this goes red.
*/
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
getDisplayTransform,
TRANSFORMED_TYPES,
} from "@/lib/measurements/display-transform";
const SRC = join(process.cwd(), "src");
/** The set of display-unit strings a transformed type can render. */
const TRANSFORMED_UNITS: ReadonlySet<string> = new Set(
[...TRANSFORMED_TYPES].flatMap((type) => [
getDisplayTransform(type, "metric").displayUnit,
getDisplayTransform(type, "imperial").displayUnit,
]),
);
/** Swept surfaces: the dashboard client + every insights sub-page. */
function sweptFiles(): string[] {
return [
join(SRC, "app", "page-client.tsx"),
...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
join(SRC, rel),
),
];
}
describe("unit-preference display guard", () => {
it("sweeps a non-empty set of surfaces", () => {
// Guards against a glob that silently matches nothing (a green void).
expect(sweptFiles().length).toBeGreaterThan(10);
});
it("no swept surface hardcodes a transformed type's display unit", () => {
const offenders: string[] = [];
for (const file of sweptFiles()) {
const src = readFileSync(file, "utf8");
for (const unit of TRANSFORMED_UNITS) {
// Match `unit="kg"` / `yAxisUnit="°C"` exactly (closing quote pinned so
// `unit="kg/m²"` — the excluded BMI unit — never trips it).
for (const prop of ["unit", "yAxisUnit"]) {
if (src.includes(`${prop}="${unit}"`)) {
offenders.push(`${file}: ${prop}="${unit}"`);
}
}
}
}
expect(offenders).toEqual([]);
});
it("no insights page hardcodes a numeric valueScale literal", () => {
// Transformed metrics resolve their scale from the transform; a literal
// `valueScale={3.6}` is the pre-fix hand-rolled scaling that double-scales.
const literal = /valueScale=\{\s*-?\d/;
const offenders = sweptFiles().filter((file) =>
literal.test(readFileSync(file, "utf8")),
);
expect(offenders).toEqual([]);
});
it("every transformed type carries both a metric and an imperial branch", () => {
for (const type of TRANSFORMED_TYPES) {
const metric = getDisplayTransform(type, "metric");
const imperial = getDisplayTransform(type, "imperial");
expect(metric.displayUnit).toBeTruthy();
expect(imperial.displayUnit).toBeTruthy();
expect(Number.isFinite(metric.factor)).toBe(true);
expect(Number.isFinite(imperial.factor)).toBe(true);
}
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: preference,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
describe("MeasurementForm — preferred-unit value label", () => {
it("labels the weight field in kg for a metric user", () => {
preference = "metric";
const html = render();
expect(html).toContain("kg");
expect(html).not.toContain("lb");
// The metric placeholder (kg) is shown, not the imperial one.
expect(html).toContain('placeholder="75.5"');
});
it("labels the weight field in lb for an imperial user", () => {
preference = "imperial";
const html = render();
expect(html).toContain("lb");
// The imperial placeholder (lb) is shown.
expect(html).toContain('placeholder="165"');
});
});
+46
View File
@@ -89,6 +89,52 @@ describe("logMcpMeasurement", () => {
);
});
it("converts a lb unit hint to canonical kg before writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 210,
unit: "lb",
idempotencyKey: "key-lb",
});
expect(result.status).toBe("written");
const data = measurement.create.mock.calls[0][0].data;
expect(data.unit).toBe("kg");
// 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim).
expect(data.value).toBeCloseTo(210 * 0.45359237, 5);
if (result.status === "written") {
expect(result.measurement.unit).toBe("kg");
}
});
it("refuses an unsupported unit hint without writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 12,
unit: "stone",
idempotencyKey: "key-stone",
});
expect(result.status).toBe("unsupported_unit");
expect(measurement.create).not.toHaveBeenCalled();
});
it("stamps canonical kg when the unit hint is omitted", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
type: "WEIGHT",
value: 80,
idempotencyKey: "key-none",
});
expect(result.status).toBe("written");
const data = measurement.create.mock.calls[0][0].data;
expect(data.unit).toBe("kg");
expect(data.value).toBe(80);
});
it("refuses a non-capturable / clinical-only type without writing", async () => {
const result = await logMcpMeasurement({
userId: "u-1",
@@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
invertDisplayTransform,
getDisplayTransform,
hasDisplayTransform,
TRANSFORMED_TYPES,
DEFAULT_UNIT_PREFERENCE,
} from "../display-transform";
@@ -65,4 +69,116 @@ describe("display-transform", () => {
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.05 lb.
expect(
applyDisplayTransform(10, getDisplayTransform(type, "imperial")),
).toBeCloseTo(22.0462, 3);
}
});
it("converts WAIST_CIRCUMFERENCE cm → in", () => {
const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial");
expect(imperial.displayUnit).toBe("in");
// 84 cm → 33.07 in.
expect(applyDisplayTransform(84, imperial)).toBeCloseTo(33.07, 2);
});
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).
expect(applyDisplayTransform(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)", () => {
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 = applyDisplayTransform(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();
}
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { resolveToCanonicalUnit } from "../unit-aliases";
describe("resolveToCanonicalUnit", () => {
it("passes the canonical unit through case-insensitively", () => {
expect(resolveToCanonicalUnit("WEIGHT", 80, "kg")).toEqual({
value: 80,
unit: "kg",
});
expect(resolveToCanonicalUnit("WEIGHT", 80, "KG")).toEqual({
value: 80,
unit: "kg",
});
expect(resolveToCanonicalUnit("BLOOD_GLUCOSE", 95, "MG/DL")).toEqual({
value: 95,
unit: "mg/dL",
});
});
it("converts a lb weight to canonical kg (exact factor)", () => {
const out = resolveToCanonicalUnit("WEIGHT", 210, "lb");
expect(out?.unit).toBe("kg");
expect(out?.value).toBeCloseTo(210 * 0.45359237, 6);
// aliases
for (const alias of ["lbs", "pound", "pounds", "LB"]) {
expect(resolveToCanonicalUnit("WEIGHT", 1, alias)?.unit).toBe("kg");
}
});
it("converts lb for every body-mass metric, not just WEIGHT", () => {
for (const type of ["MUSCLE_MASS", "FAT_MASS", "BONE_MASS"]) {
const out = resolveToCanonicalUnit(type, 22.0462, "lb");
expect(out?.unit).toBe("kg");
expect(out?.value).toBeCloseTo(10, 3);
}
});
it("converts waist inches to canonical cm", () => {
const out = resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 33, "in");
expect(out?.unit).toBe("cm");
expect(out?.value).toBeCloseTo(83.82, 2);
});
it("converts an absolute °F reading to canonical celsius", () => {
const out = resolveToCanonicalUnit("BODY_TEMPERATURE", 98.6, "°F");
expect(out?.unit).toBe("celsius");
expect(out?.value).toBeCloseTo(37, 5);
// an explicit celsius spelling also normalises to the canonical string
expect(resolveToCanonicalUnit("BODY_TEMPERATURE", 37, "°C")).toEqual({
value: 37,
unit: "celsius",
});
});
it("converts glucose mmol/L to canonical mg/dL", () => {
const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L");
expect(out?.unit).toBe("mg/dL");
expect(out?.value).toBeCloseTo(5.3 * 18.016, 3);
});
it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => {
expect(resolveToCanonicalUnit("WEIGHT", 12, "stone")).toBeNull();
expect(resolveToCanonicalUnit("WEIGHT", 12, "°F")).toBeNull();
// lb is not a waist unit
expect(resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 12, "lb")).toBeNull();
// mmol/L is not a weight unit
expect(resolveToCanonicalUnit("WEIGHT", 12, "mmol/L")).toBeNull();
});
});