test(units): sweep every display tree for unit literals

The unit-preference guard covered the dashboard, the insights pages and
the target surfaces, and the class kept turning up beside them. It now
also walks src/components, src/app, src/lib/insights and the doctor
report and export modules for the glucose units anywhere in code and
for kg, km and °C in display strings, with comments stripped. Each file
that legitimately carries one is listed with its exact line budget and
the reason: the conversion and the pickers, input parsing, display-kind
tags, canonical wire and registry units, the SI policy for clinician
artefacts, and four prompt texts still to move. The sweep asserts a
match floor, so a broken walk cannot pass as a clean tree.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:10:17 +02:00
1 parent 8a11e60bb3
commit b788b5b8b2
1 file changed
+218 -1
@@ -13,7 +13,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
import { describe, it, expect } from "vitest";
@@ -207,3 +207,220 @@ describe("unit-preference display guard", () => {
}
});
});
/**
* The wide sweep (#1067). The block above guards the surfaces that were
* wired for metric/imperial; the class kept reappearing everywhere else — a
* clinician view without units, status notes and narratives pinned to mg/dL
* and kilograms, a glucose threshold editor in mg/dL for an mmol/L account,
* workout distances in kilometres for an imperial reader. Each one was a
* unit literal in a display string.
*
* So every display-bearing tree is swept for the literals a preference
* changes: the two glucose units anywhere in code, and kilograms,
* kilometres and degrees Celsius where they sit in a display string (a bare
* quoted unit, a unit after an interpolation or a number, a unit closing a
* string, a per-kilometre pace). Comments are stripped first. A file that
* legitimately carries one is listed below with the exact number of matching
* lines and the reason; any other file, or one more line in a listed file,
* fails. A listed file that drops a line fails too, so the budget follows
* the code down instead of leaving headroom for the next literal.
*
* The limit, stated: this is a line scanner. A unit spelled through a
* variable named after it, or a literal split across lines, passes. The
* message bundles are not swept either (the fever and cycle-glucose labels
* lived there).
*/
const WIDE_SWEEP_ROOTS = [
"components/",
"app/",
"lib/insights/",
"lib/doctor-report",
"lib/export",
];
const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
// Glucose, anywhere in code.
/mg\/dL|mmol\/L/,
// A bare quoted unit: "kg", 'km', `°C`.
/(["'`])\s*(?:kg|km|°C)\s*\1/,
// A unit after an interpolation: `${x} kg`, {value} km in JSX.
/\}\s?(?:kg|km|°C)(?![\w/²])/,
// A unit after a number: "75 kg", "37.0 °C".
/\d\s?(?:kg|km|°C)(?![\w/²])/,
// A unit closing a string or a JSX text run: " km<", " kg\"".
/\s(?:kg|km|°C)(?=["'`<])/,
// A per-kilometre pace.
/\/km\b/,
];
/**
* Files allowed to carry a unit literal, with the exact number of matching
* lines and why. `kg/m²` (BMI) never matches; every other hit is listed.
*/
const UNIT_LITERAL_ALLOWLIST: Record<
string,
{ lines: number; reason: string }
> = {
// ── The conversion and the choice themselves ──
"components/settings/glucose-unit-select.tsx": {
lines: 2,
reason: "the glucose unit picker: its options are the two units",
},
"components/onboarding/units-screen.tsx": {
lines: 3,
reason: "the onboarding unit picker: its options are the two units",
},
"components/measurements/measurement-form.tsx": {
lines: 11,
reason:
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
},
"components/measurements/measurement-list.tsx": {
lines: 2,
reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL",
},
"components/settings/import-panel/import-examples.ts": {
lines: 3,
reason: "input parsing: CSV examples showing the units an import accepts",
},
"components/onboarding/first-result-screen.tsx": {
lines: 1,
reason: "decimals chosen by the resolved glucose unit, not a label",
},
"app/insights/blood-glucose/page.tsx": {
lines: 1,
reason: "branches on the resolved glucose unit to scale the chart",
},
"components/insights/glucose/glucose-clinical-panel.tsx": {
lines: 1,
reason: "branches on the resolved glucose unit for its decimals",
},
"components/insights/health-score-pillar-detail.ts": {
lines: 2,
reason:
"branches on the canonical observed unit before converting to the reader's",
},
// ── Display-kind tags, converted through the preference at render ──
"components/cycle/cycle-phase-crosstab.tsx": {
lines: 1,
reason: "a display-kind tag; rows convert through getReadingTransform",
},
"lib/insights/mood-crosstab.ts": {
lines: 2,
reason: "a display-kind tag; the weight row converts through the hook",
},
"components/insights/mood/mood-factor-metric-crosstab.tsx": {
lines: 2,
reason: "a display-kind tag; the weight row converts through the hook",
},
// ── Canonical by contract ──
"lib/insights/metric-status-registry.ts": {
lines: 11,
reason:
"canonical registry units; metric-status.ts converts to the reader's before writing",
},
"lib/export.ts": {
lines: 1,
reason: "canonical default when an export caller passes no glucose unit",
},
"app/api/export/route.ts": {
lines: 1,
reason: "the legacy export endpoint's canonical storage-unit contract",
},
"app/api/measurements/series/route.ts": {
lines: 4,
reason:
"canonical wire units; glucose is overridden to the reader's unit per request",
},
"lib/doctor-report/stat-display.ts": {
lines: 3,
reason:
"SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)",
},
"components/insights/workout-detail/splits.tsx": {
lines: 1,
reason: "kilometre splits are cut server-side; their pace is per km",
},
"app/privacy/page.tsx": {
lines: 2,
reason: "prose naming both glucose units the app offers",
},
// ── Still canonical; the prompt-unit pass for these is open (#1067) ──
"lib/insights/comprehensive-generate.ts": {
lines: 1,
reason: "comprehensive prompt still states weight in kg",
},
"lib/insights/signals-of-day.ts": {
lines: 1,
reason: "briefing signal still states weight in kg",
},
"lib/insights/weight-status.ts": {
lines: 1,
reason: "weight status snapshot still states kg",
},
"lib/insights/glp1-plateau.ts": {
lines: 2,
reason: "GLP-1 plateau prompt context still states kg",
},
};
function unitLiteralLines(): Map<string, string[]> {
const files = walkSourceFiles(SRC, { floor: 1400 }).filter(
(rel) =>
WIDE_SWEEP_ROOTS.some((root) => rel.startsWith(root)) &&
!rel.includes("__tests__") &&
!/\.test\.tsx?$/.test(rel),
);
if (files.length < 1200) {
throw new Error(
`unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`,
);
}
const hits = new Map<string, string[]>();
for (const rel of files) {
const lines = stripComments(readFileSync(join(SRC, rel), "utf8"))
.split("\n")
.filter((line) => UNIT_LITERAL_PATTERNS.some((p) => p.test(line)))
.map((line) => line.trim());
if (lines.length > 0) hits.set(rel, lines);
}
return hits;
}
describe("unit-preference display guard — the wide sweep", () => {
const hits = unitLiteralLines();
it("finds the literals it is meant to find (a sweep that matches nothing proves nothing)", () => {
const total = [...hits.values()].reduce((n, l) => n + l.length, 0);
expect(total).toBeGreaterThanOrEqual(40);
expect(hits.size).toBeGreaterThanOrEqual(15);
});
it("no display surface carries a unit literal outside the allowlist", () => {
const offenders: string[] = [];
for (const [rel, lines] of hits) {
const allowed = UNIT_LITERAL_ALLOWLIST[rel];
if (!allowed) {
offenders.push(`${rel}: ${lines.join(" | ")}`);
} else if (lines.length > allowed.lines) {
offenders.push(
`${rel}: ${lines.length} unit-literal lines, allowlisted ${allowed.lines}: ${lines.join(" | ")}`,
);
}
}
expect(offenders).toEqual([]);
});
it("every allowlist entry still matches exactly its budget, with a reason", () => {
const stale: string[] = [];
for (const [rel, allowed] of Object.entries(UNIT_LITERAL_ALLOWLIST)) {
expect(allowed.reason.length).toBeGreaterThan(10);
const found = hits.get(rel)?.length ?? 0;
if (found !== allowed.lines) {
stale.push(`${rel}: allowlisted ${allowed.lines}, found ${found}`);
}
}
expect(stale).toEqual([]);
});
});