mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12: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,104 @@
|
||||
/**
|
||||
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
|
||||
* canonical SI, in place. Companion to
|
||||
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
|
||||
*
|
||||
* SAFETY:
|
||||
* - Dry-run by default. Writes ONLY when called with `--execute`.
|
||||
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
|
||||
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
|
||||
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
|
||||
* unit is unrecognised, is never touched.
|
||||
* - Each row is rewritten to the canonical `{ value, unit }` the same
|
||||
* resolver the CSV importer + MCP write path use produces, so the number is
|
||||
* re-expressed, never re-labelled in place (which would corrupt it).
|
||||
* - Affected users' measurement caches are busted at the end.
|
||||
*
|
||||
* This repairs the storage LABEL + value for the recognised-alias legacy rows
|
||||
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
|
||||
* but whose value was a foreign unit — that class is not mechanically
|
||||
* identifiable (see the diagnose script).
|
||||
*
|
||||
* Run (inside the app container):
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
|
||||
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const execute = process.argv.includes("--execute");
|
||||
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const convertibleGroups = groups.filter((g) => {
|
||||
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
|
||||
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
|
||||
});
|
||||
|
||||
if (convertibleGroups.length === 0) {
|
||||
console.log(
|
||||
"Nothing to convert — no recognised non-canonical units found.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedUsers = new Set<string>();
|
||||
let converted = 0;
|
||||
|
||||
for (const g of convertibleGroups) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { type: g.type, unit: g.unit },
|
||||
select: { id: true, userId: true, value: true, unit: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const resolved = resolveToCanonicalUnit(
|
||||
g.type,
|
||||
row.value,
|
||||
row.unit as string,
|
||||
);
|
||||
if (!resolved) continue; // defensive; group already filtered
|
||||
converted += 1;
|
||||
affectedUsers.add(row.userId);
|
||||
console.log(
|
||||
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
|
||||
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
|
||||
);
|
||||
if (execute) {
|
||||
await prisma.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: resolved.value, unit: canonical },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (execute) {
|
||||
for (const userId of affectedUsers) {
|
||||
invalidateUserMeasurements(userId, { evict: true });
|
||||
}
|
||||
console.log(
|
||||
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
|
||||
"Rollups recompute on the next read/backfill.",
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
|
||||
"would be converted. Re-run with --execute to write.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
|
||||
*
|
||||
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
|
||||
* one historical leak was the MCP `log_measurement` tool, which persisted a
|
||||
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
|
||||
* column differs from its type's canonical unit is such a legacy row, and it
|
||||
* mislabels every display surface that trusts the column.
|
||||
*
|
||||
* This groups measurements by `(type, unit)` and prints every group whose
|
||||
* `unit` is not the canonical unit for its type, flagging whether the stored
|
||||
* unit is a RECOGNISED alias (mechanically convertible by the companion
|
||||
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
|
||||
*
|
||||
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
|
||||
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
|
||||
* into the old kg-labelled entry form). Those are indistinguishable from real
|
||||
* canonical readings; the reporter edits them via the record list.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const groups = await prisma.measurement.groupBy({
|
||||
by: ["type", "unit"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const mismatches = groups.filter((g) => {
|
||||
if (g.unit === null) return false;
|
||||
return g.unit !== getUnitForType(g.type);
|
||||
});
|
||||
|
||||
if (mismatches.length === 0) {
|
||||
console.log("No non-canonical measurement units found. Storage is clean.");
|
||||
return;
|
||||
}
|
||||
|
||||
let convertible = 0;
|
||||
let unknown = 0;
|
||||
console.log(
|
||||
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
|
||||
);
|
||||
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
|
||||
const canonical = getUnitForType(g.type);
|
||||
// Probe convertibility with a neutral value — only the recognition matters.
|
||||
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
|
||||
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
|
||||
if (recognised) convertible += g._count._all;
|
||||
else unknown += g._count._all;
|
||||
console.log(
|
||||
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
|
||||
`rows=${g._count._all} [${status}]`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
|
||||
);
|
||||
console.log(
|
||||
"Convert the recognised aliases with:\n" +
|
||||
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -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