diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index ff91da3c0..0a7c638ec 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -31,13 +31,32 @@ const TRANSFORMED_UNITS: ReadonlySet = 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"); diff --git a/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx b/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx new file mode 100644 index 000000000..91d0a0c75 --- /dev/null +++ b/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx @@ -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( + + + , + ); +} + +describe(" 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")); + }); +}); diff --git a/src/components/targets/__tests__/target-edit-sheet.test.tsx b/src/components/targets/__tests__/target-edit-sheet.test.tsx index 115c8ba2c..3a6f72606 100644 --- a/src/components/targets/__tests__/target-edit-sheet.test.tsx +++ b/src/components/targets/__tests__/target-edit-sheet.test.tsx @@ -116,6 +116,29 @@ describe("", () => { ).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( + , + ), + ), + ).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. diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts new file mode 100644 index 000000000..da8702179 --- /dev/null +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -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); + }); +});