test(targets): pin the target unit round-trip and extend the display guard

Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.

Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.

Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 03:24:19 +02:00
1 parent bf73755899
commit c86de7d317
4 files changed
+389 -1

No files matched your search

@@ -31,13 +31,32 @@ const TRANSFORMED_UNITS: ReadonlySet<string> = new Set(
]),
);
/** Swept surfaces: the dashboard client + every insights sub-page. */
/**
* v1.32.27 — the target/threshold subsystem. These three files are the
* coupled display+edit unit: the reference panel that RENDERS a target
* band, the sheet that EDITS it, and the settings editor that edits the
* same thresholds from the other end. A partial wire here is worse than
* none — a converted display over a canonical save silently rewrites
* the user's stored target — so they are swept for hardcoded units AND
* asserted to route through the preference hook.
*/
const TARGET_SURFACES = [
join(SRC, "components", "insights", "metric-target-summary.tsx"),
join(SRC, "components", "targets", "target-edit-sheet.tsx"),
join(SRC, "components", "settings", "thresholds-editor-section.tsx"),
];
/**
* Swept surfaces: the dashboard client, every insights sub-page, and the
* target/threshold surfaces.
*/
function sweptFiles(): string[] {
return [
join(SRC, "app", "page-client.tsx"),
...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
join(SRC, rel),
),
...TARGET_SURFACES,
];
}
@@ -74,6 +93,37 @@ describe("unit-preference display guard", () => {
expect(offenders).toEqual([]);
});
it("every target/threshold surface resolves its unit from the preference", () => {
// The tripwire for a silent un-wire: delete the hook import from any
// of the three and the surface falls back to canonical kilograms
// while its sibling still shows pounds.
// The call site, not the import — swapping the hook out for a
// hardcoded "metric" literal while leaving the import behind is
// exactly the un-wire this has to catch.
const offenders = TARGET_SURFACES.filter(
(file) => !readFileSync(file, "utf8").includes("useUnitDisplay()"),
);
expect(offenders).toEqual([]);
});
it("every target/threshold surface converts through the shared adapter", () => {
// One adapter owns seed, guardrail, save, and unit label for all
// three surfaces. A surface that hand-rolls its own conversion (or
// announces `METRIC_BOUNDS[metric].unit`, the canonical symbol)
// is how the display and the edit halves drift apart.
const offenders: string[] = [];
for (const file of TARGET_SURFACES) {
const src = readFileSync(file, "utf8");
if (!src.includes("resolveTargetUnitAdapter")) {
offenders.push(`${file}: no adapter`);
}
if (/METRIC_BOUNDS\[[^\]]+\]\.unit/.test(src)) {
offenders.push(`${file}: canonical bound unit rendered`);
}
}
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");
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.32.27 — the target reference panel follows the metric/imperial
* preference.
*
* `/api/insights/targets` stays canonical (kg for weight); the panel
* converts once and hands the converted pair to the range bar, the
* status pill, and the edit sheet's seed. These assertions read the
* range bar's axis labels out of the SSR markup — the one place the
* band endpoints and their unit are rendered as plain text rather than
* inside a portalled tooltip.
*/
const authUser = vi.hoisted(() => ({
value: null as { unitPreference?: string } | null,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: authUser.value })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
/** A weight target as the route emits it: canonical kilograms. */
const weightPayload = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 72.5,
average30: 73.1,
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "green" },
source: "WHO",
daysInRange7d: 5,
daysLogged7d: 7,
daysInRange30d: 20,
daysLogged30d: 30,
insufficientData: false,
consistency7d: ["in", "in", "out", "in", "in", "near", "in"] as const,
},
],
bpDiastolic: { current: null, average30: null, range: null },
};
function renderWeightPanel(unitPreference: string | null): string {
authUser.value = unitPreference ? { unitPreference } : null;
useQueryMock.mockReturnValue({ data: weightPayload });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug="weight" />
</I18nProvider>,
);
}
describe("<MetricTargetSummary> under a unit preference", () => {
it("renders the weight band in pounds for an imperial account", () => {
const html = renderWeightPanel("imperial");
// 60 kg → 132.3 lb, 80 kg → 176.4 lb.
expect(html).toContain("132.3 lb");
expect(html).toContain("176.4 lb");
expect(html).not.toContain("60 kg");
expect(html).not.toContain("80 kg");
});
it("leaves the weight band in kilograms for a metric account", () => {
const html = renderWeightPanel("metric");
expect(html).toContain("60 kg");
expect(html).toContain("80 kg");
expect(html).not.toContain("lb");
});
it("treats a payload with no preference as metric", () => {
// A stale `/api/auth/me` payload must never silently rescale a band.
expect(renderWeightPanel(null)).toBe(renderWeightPanel("metric"));
});
});
@@ -116,6 +116,29 @@ describe("<TargetEditSheet>", () => {
).not.toThrow();
});
it("mounts a weight target seeded in pounds without throwing", () => {
// v1.32.27 — an imperial account gets `unit="lb"` and a
// pound-converted seed from the panel; the sheet projects the
// kilogram guardrails into the same unit and inverts on save. The
// arithmetic itself is pinned in `target-unit-display.test.ts`
// (Radix portals the body, so SSR cannot read the fields back);
// this covers the mount path composing cleanly.
expect(() =>
renderToStaticMarkup(
withProviders(
<TargetEditSheet
targetType="WEIGHT"
targetLabel="Weight"
unit="lb"
initialRange={{ min: 132.3, max: 176.4 }}
open={true}
onOpenChange={vi.fn()}
/>,
),
),
).not.toThrow();
});
it("mounts the body without throwing for a derived metric (BMI)", () => {
// BMI is derived from weight + height — the body shows the
// explanatory hint instead of the editable inputs.
@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { resolveTargetUnitAdapter } from "../target-unit-display";
/**
* v1.32.27 — the target/threshold unit adapter.
*
* The dangerous failure mode this subsystem exists to avoid is a HALF
* wire: display converted, edit seed or save left canonical, so the
* user's typed number silently changes meaning between save and reopen.
* These assertions pin the whole loop — seed, guardrail, save, reopen —
* for the weight class (a pure scale) and for temperature (the affine
* one, where an offset leaking into a delta is the classic bug).
*/
const KG_PER_LB = 0.45359237;
describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("leaves a weight threshold bit-for-bit untouched", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("kg");
// Not "close to" — exactly the same double, in both directions.
for (const value of [30, 68.04, 72.5, 300, 88.123456789]) {
expect(units.toDisplay(value)).toBe(value);
expect(units.toCanonical(value)).toBe(value);
expect(units.toDisplayDelta(value)).toBe(value);
}
expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 30, max: 300 });
expect(units.step).toBe(0.1);
});
it("leaves an untransformed metric alone under either preference", () => {
for (const preference of ["metric", "imperial"] as const) {
const units = resolveTargetUnitAdapter(
"BLOOD_PRESSURE_SYS",
"mmHg",
preference,
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmHg");
expect(units.toDisplay(118)).toBe(118);
expect(units.toCanonical(118)).toBe(118);
expect(units.bounds(METRIC_BOUNDS.BLOOD_PRESSURE_SYS)).toEqual({
min: 80,
max: 220,
});
}
});
it("is inert for the keys the edit sheet passes on its non-metric paths", () => {
// A derived card (BMI, mood, medication compliance) has no editable
// threshold and the sheet resolves the adapter with an empty key;
// glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick
// up a second conversion on top.
for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) {
const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmol/L");
expect(units.toDisplay(5.5)).toBe(5.5);
expect(units.toCanonical(5.5)).toBe(5.5);
}
});
it("keeps the whole-hundred step for a step-count target", () => {
for (const preference of ["metric", "imperial"] as const) {
expect(
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step,
).toBe(100);
}
});
});
describe("resolveTargetUnitAdapter — imperial weight class", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial");
it("announces pounds and a one-decimal step", () => {
expect(units.rescales).toBe(true);
expect(units.unit).toBe("lb");
expect(units.step).toBe(0.1);
});
it("seeds a stored kilogram threshold into pounds", () => {
// 68.04 kg is what 150 lb persists as; it must read back as 150.
expect(units.toDisplay(68.04)).toBe(150);
expect(units.toDisplay(80)).toBe(176.4);
});
it("saves a typed pound value back as canonical kilograms", () => {
expect(units.toCanonical(150)).toBe(68.04);
// 80 kg renders as 176.4 lb; typing 176.4 back lands on 80.01 kg,
// one hundredth off — the unavoidable cost of a one-decimal pound
// field. What must NOT drift is the number the user typed, which
// the round-trip sweep below pins.
expect(units.toCanonical(176.4)).toBe(80.01);
});
it("round-trips every typed value it can render (save → reopen)", () => {
// The property that matters: type a number, save, reopen, see the
// same number. Swept across the whole editable window at the
// input's own step granularity.
for (let typed = 70; typed <= 660; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
const canonical = units.toCanonical(shown);
expect(units.toDisplay(canonical)).toBe(shown);
}
});
it("rounds guardrails inward so a value typed at the limit still saves", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
// A naive round of the 30 kg floor gives 66.1 lb, which inverts to
// 29.98 kg and the server rejects it. The ceiling keeps it legal.
expect(bounds.min).toBe(66.2);
expect(bounds.max).toBe(661.3);
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS.WEIGHT.min,
);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS.WEIGHT.max,
);
});
it("accepts an imperial-valid value a kilogram bound would have rejected", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
// 150 lb sails past the raw canonical 30–300 check as a NUMBER only
// because the window is now expressed in pounds. Against the
// unconverted kilogram window a 400 lb target would have been read
// as "400 > 300, rejected" while it is a legitimate 181.44 kg.
for (const typed of [150, 400, 660]) {
expect(typed).toBeGreaterThanOrEqual(bounds.min);
expect(typed).toBeLessThanOrEqual(bounds.max);
const canonical = units.toCanonical(typed);
expect(canonical).toBeGreaterThanOrEqual(METRIC_BOUNDS.WEIGHT.min);
expect(canonical).toBeLessThanOrEqual(METRIC_BOUNDS.WEIGHT.max);
}
});
it("still rejects a genuinely out-of-range value", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
for (const typed of [12, 66.1, 661.4, 900]) {
expect(typed < bounds.min || typed > bounds.max).toBe(true);
}
});
it("carries the same treatment across the whole weight class", () => {
for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) {
const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial");
expect(adapter.unit).toBe("lb");
const bounds = adapter.bounds(METRIC_BOUNDS[metric]);
expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS[metric].min,
);
expect(adapter.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS[metric].max,
);
}
});
it("matches the exact pound definition, not an approximation", () => {
expect(units.toCanonical(100)).toBe(
Math.round(100 * KG_PER_LB * 100) / 100,
);
});
});
describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
const units = resolveTargetUnitAdapter(
"BODY_TEMPERATURE",
"celsius",
"imperial",
);
it("shifts absolute readings but never a difference", () => {
expect(units.unit).toBe("°F");
expect(units.toDisplay(37)).toBe(98.6);
expect(units.toDisplay(38)).toBe(100.4);
// A one-degree-Celsius spread is a 1.8-degree-Fahrenheit spread. If
// the delta ever picked up the +32 absolute shift it would read
// 33.8 — the exact bug `applyDisplayTransformDelta` exists to stop.
expect(units.toDisplayDelta(1)).toBe(1.8);
expect(units.toDisplayDelta(1)).not.toBe(33.8);
});
it("keeps a rendered difference offset-free by construction", () => {
// The range bar computes its "x above target" sentence from two
// already-converted absolutes. That subtraction must agree with the
// factor-only delta conversion, or the two would disagree by 32.
for (const [a, b] of [
[38, 37],
[37.4, 36.6],
[41, 35],
]) {
const difference = units.toDisplay(a) - units.toDisplay(b);
expect(difference).toBeCloseTo(units.toDisplayDelta(a - b), 6);
}
});
it("round-trips a typed Fahrenheit target back to itself", () => {
for (let typed = 95; typed <= 106; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
expect(units.toDisplay(units.toCanonical(shown))).toBe(shown);
}
});
it("is the exact identity for a metric account", () => {
const metricUnits = resolveTargetUnitAdapter(
"BODY_TEMPERATURE",
"celsius",
"metric",
);
expect(metricUnits.rescales).toBe(false);
expect(metricUnits.toDisplay(37)).toBe(37);
expect(metricUnits.toCanonical(37)).toBe(37);
expect(metricUnits.toDisplayDelta(1)).toBe(1);
});
it("rounds an affine guardrail inward on both ends", () => {
const bounds = units.bounds({ min: 30, max: 45 });
// 30 °C = 86 °F and 45 °C = 113 °F exactly; inward rounding must not
// drift a clean bound off its own value.
expect(bounds).toEqual({ min: 86, max: 113 });
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(30);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45);
});
});