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