mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user