mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
607 lines
23 KiB
TypeScript
607 lines
23 KiB
TypeScript
/**
|
||
* 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 { readdirSync, readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
|
||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||
|
||
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,
|
||
]),
|
||
);
|
||
|
||
/**
|
||
* 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"),
|
||
];
|
||
|
||
/**
|
||
* v1.32.30 — the profile height surfaces. Height is not a
|
||
* `MeasurementType`, so `display-transform.ts` never reached it and it
|
||
* stayed in centimetres for two releases after everything else moved.
|
||
* These three are the coupled entry unit: the two forms that write
|
||
* `User.heightCm` and the control they share. A partial wire here is
|
||
* the same failure the target block guards against — a converted field
|
||
* over a canonical save silently rewrites the stored height.
|
||
*/
|
||
/**
|
||
* One entry per surface. A surface is a group of files because the
|
||
* onboarding step keeps the preference and the adapter where the save
|
||
* happens and renders the control from its own inputs file — the three
|
||
* conditions below are asserted across the group, not per file, so
|
||
* splitting a form does not read as an un-wire.
|
||
*/
|
||
const HEIGHT_SURFACES = [
|
||
[
|
||
join(SRC, "components", "onboarding", "baseline-form.tsx"),
|
||
join(SRC, "components", "onboarding", "baseline-fields.tsx"),
|
||
],
|
||
[join(SRC, "components", "settings", "account-section", "index.tsx")],
|
||
];
|
||
|
||
const HEIGHT_CONTROL = join(
|
||
SRC,
|
||
"components",
|
||
"profile",
|
||
"height-field-control.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"),
|
||
...walkSourceFiles(SRC, { floor: 700, extensions: [".tsx"] })
|
||
.filter(
|
||
(rel) => rel.startsWith("app/insights/") && rel.endsWith("/page.tsx"),
|
||
)
|
||
.map((rel) => join(SRC, rel)),
|
||
...TARGET_SURFACES,
|
||
];
|
||
}
|
||
|
||
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 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 height entry surface resolves through the profile adapter", () => {
|
||
// Same tripwire as the target block: drop the hook or the adapter
|
||
// from one form and it falls back to centimetres while its sibling
|
||
// shows feet and inches.
|
||
const offenders: string[] = [];
|
||
for (const files of HEIGHT_SURFACES) {
|
||
const surface = files.join(" + ");
|
||
const src = files.map((file) => readFileSync(file, "utf8")).join("\n");
|
||
if (!src.includes("useUnitDisplay()")) {
|
||
offenders.push(`${surface}: no preference hook`);
|
||
}
|
||
if (!src.includes("resolveHeightUnitAdapter")) {
|
||
offenders.push(`${surface}: no height adapter`);
|
||
}
|
||
// Word-bounded: a renamed local wrapper whose name merely starts
|
||
// with the control's satisfies a substring check while rendering
|
||
// something else entirely.
|
||
if (!/\bHeightFieldControl\b/.test(src)) {
|
||
offenders.push(`${surface}: hand-rolled height input`);
|
||
}
|
||
// The canonical centimetre guardrails must come from the
|
||
// adapter's inward-rounded bounds, never from a literal.
|
||
if (/min=\{50\}|max=\{300\}/.test(src)) {
|
||
offenders.push(`${surface}: hardcoded centimetre guardrail`);
|
||
}
|
||
}
|
||
expect(offenders).toEqual([]);
|
||
});
|
||
|
||
it("the height control announces its units from the bundle", () => {
|
||
const src = readFileSync(HEIGHT_CONTROL, "utf8");
|
||
// A quoted "cm" / "ft" / "in" here is a hardcoded unit label; every
|
||
// unit string on this control resolves through `t(...)`.
|
||
expect(src).not.toMatch(/["'](cm|ft|in)["']/);
|
||
expect(src).toContain('t("common.feet")');
|
||
expect(src).toContain('t("common.inches")');
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 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 text the AI and the Coach write is swept the same way: the prompt and
|
||
* snapshot builders (`lib/ai/`), the jobs that write stored text
|
||
* (`lib/jobs/`), the target and daily-digest producers. A prompt that
|
||
* states a threshold or a band in kilograms, °C or mg/dL hands a reader on
|
||
* pounds, °F or mmol/L two units for one quantity, and the model mixes
|
||
* them. Prose units ("5 metres", "kg/week") are matched as well, because a
|
||
* prompt is prose.
|
||
*
|
||
* 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). The producer check below covers what a scanner cannot: a
|
||
* text producer that prints a canonical number with no unit at all.
|
||
*/
|
||
const WIDE_SWEEP_ROOTS = [
|
||
"components/",
|
||
"app/",
|
||
"lib/insights/",
|
||
"lib/doctor-report",
|
||
"lib/export",
|
||
"lib/ai/",
|
||
"lib/jobs/",
|
||
"lib/targets/",
|
||
"lib/daily/",
|
||
// The series route's readers, moved out of `app/` with their unit tokens.
|
||
"lib/measurements/series/",
|
||
];
|
||
|
||
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/,
|
||
// A rate per kilogram written into prose: "kg/week" (BMI's kg/m² excepted).
|
||
/\bkg\/(?!m²)/,
|
||
// A unit spelled out after a number or an interpolation: "5 metres".
|
||
/(?:\d|\})\s?(?:metres?|kilograms?)\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: 1,
|
||
reason:
|
||
"a display-kind tag; the weight row converts through crosstabReading",
|
||
},
|
||
"components/insights/metric-crosstab.tsx": {
|
||
lines: 3,
|
||
reason:
|
||
"the boards' display kinds and the storage token each is in; rows convert through getReadingTransform and label through measurementUnitLabel",
|
||
},
|
||
// ── 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",
|
||
},
|
||
"lib/measurements/series/kinds.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)",
|
||
},
|
||
"lib/doctor-report-pdf/activity-effect-sections.ts": {
|
||
lines: 2,
|
||
reason:
|
||
"SI by policy: the report's workout distance is in km, like every clinician artefact",
|
||
},
|
||
"components/clinician/activity-effect-sections.tsx": {
|
||
lines: 1,
|
||
reason:
|
||
"SI by policy: the clinician page's workout distance is in km, like the PDF",
|
||
},
|
||
"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",
|
||
},
|
||
// ── The AI and Coach producers ──
|
||
"lib/ai/insight-interpretation.ts": {
|
||
lines: 1,
|
||
reason:
|
||
"canonical band registry; interpretation-block prints the band in the reader's unit",
|
||
},
|
||
"lib/ai/prompts/shared-contracts.ts": {
|
||
lines: 3,
|
||
reason:
|
||
"the acute red-flag floors, stated in both units on purpose so the rule fires on what the person writes",
|
||
},
|
||
"lib/ai/coach/snapshot-blocks/glucose-block.ts": {
|
||
lines: 4,
|
||
reason:
|
||
"branches on the resolved glucose unit; a mg/dL reader's block stays byte-identical",
|
||
},
|
||
"lib/ai/coach/results/chart-spec.ts": {
|
||
lines: 2,
|
||
reason:
|
||
"bin widths keyed to a column unit, applied only when the table is in that unit",
|
||
},
|
||
"lib/ai/coach/coach-prose-grounding.ts": {
|
||
lines: 1,
|
||
reason:
|
||
"the reply checker's list of unit tokens it recognises in model prose",
|
||
},
|
||
"lib/ai/coach/eval/golden-cases.ts": {
|
||
lines: 5,
|
||
reason: "evaluation fixtures: scripted replies and ideal answers",
|
||
},
|
||
"lib/ai/coach/eval/red-team.ts": {
|
||
lines: 2,
|
||
reason: "adversarial evaluation inputs, written as a person would type",
|
||
},
|
||
"lib/targets/target-unit-display.ts": {
|
||
lines: 4,
|
||
reason: "the target adapter itself: picks the glucose branch",
|
||
},
|
||
"lib/targets/vitals-builder.ts": {
|
||
lines: 1,
|
||
reason:
|
||
"canonical targets DTO; every client converts through the target adapter",
|
||
},
|
||
};
|
||
|
||
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 < 1700) {
|
||
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([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The producers of text a model reads or writes from. A line scanner sees a
|
||
* unit literal; it cannot see a canonical number printed with no unit at
|
||
* all, which is how the Coach's weight block and the briefing's glucose
|
||
* signal reached readers on pounds and mmol/L. So each producer that carries
|
||
* a mass, length, temperature, speed, distance or glucose figure into a
|
||
* prompt, a snapshot or a stored sentence is named here and must call one of
|
||
* the resolvers that state a figure in the reader's units. Dropping the call
|
||
* (or the file) fails.
|
||
*/
|
||
const READER_UNIT_PRODUCERS = [
|
||
"lib/ai/coach/reference-grounding.ts",
|
||
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts",
|
||
"lib/ai/coach/snapshot-blocks/value-series-blocks.ts",
|
||
"lib/ai/coach/snapshot-blocks/workouts-block.ts",
|
||
"lib/ai/coach/cycle-snapshot.ts",
|
||
"lib/ai/coach/results/metric-table-tool.ts",
|
||
"lib/ai/prompts/weight.ts",
|
||
"lib/insights/features-units.ts",
|
||
"lib/insights/comprehensive-generate.ts",
|
||
"lib/insights/briefing-request/features.ts",
|
||
"lib/insights/weight-status.ts",
|
||
"lib/insights/metric-status.ts",
|
||
"lib/insights/narrative/period-narrative.ts",
|
||
"lib/insights/glp1-plateau.ts",
|
||
"lib/jobs/reaction-line.ts",
|
||
"lib/targets/weight-trend.ts",
|
||
];
|
||
|
||
const READER_UNIT_RESOLVER =
|
||
/\b(?:getReadingTransform|getQuantityTransform|getDisplayTransform|featuresInReaderUnits|weightFeatureInReaderUnits)\(/;
|
||
|
||
describe("unit-preference display guard — AI and Coach producers", () => {
|
||
it("names enough producers to be a check (a list that shrinks proves nothing)", () => {
|
||
expect(READER_UNIT_PRODUCERS.length).toBeGreaterThanOrEqual(16);
|
||
});
|
||
|
||
it("every producer states its figures through a reader-unit resolver", () => {
|
||
const offenders = READER_UNIT_PRODUCERS.filter(
|
||
(rel) =>
|
||
!READER_UNIT_RESOLVER.test(
|
||
stripComments(readFileSync(join(SRC, rel), "utf8")),
|
||
),
|
||
);
|
||
expect(offenders).toEqual([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Locale strings that may name a glucose unit literally, each with the reason
|
||
* it is not a reading shown in the reader's unit. Keys are dotted paths and
|
||
* apply to every locale bundle.
|
||
*/
|
||
const GLUCOSE_UNIT_LITERAL_ALLOWLIST: Record<string, string> = {
|
||
"dashboard.metric.unit.glucose":
|
||
"the unit token the dashboard summary wire hands the native client; the web resolves the glucose unit from the account instead",
|
||
"labs.biomarker.form.unitPlaceholder":
|
||
"an example of a free-text lab unit in an input placeholder, not a reading",
|
||
};
|
||
|
||
const GLUCOSE_UNIT_LITERAL = /mg\/dl|mmol\/l\b/i;
|
||
|
||
function flattenStrings(
|
||
node: unknown,
|
||
path: string,
|
||
out: Array<[string, string]>,
|
||
): void {
|
||
if (typeof node === "string") {
|
||
out.push([path, node]);
|
||
} else if (node && typeof node === "object") {
|
||
for (const [key, value] of Object.entries(node)) {
|
||
flattenStrings(value, path ? `${path}.${key}` : key, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
describe("unit-preference display guard — locale strings", () => {
|
||
const MESSAGES = join(process.cwd(), "messages");
|
||
const bundles = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
|
||
|
||
it("reads every locale bundle", () => {
|
||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||
});
|
||
|
||
it("no locale string fixes a glucose unit outside the allowlist", () => {
|
||
// A sentence that prints "70–180 mg/dL" shows mg/dL to a reader who
|
||
// chose mmol/L. Band and unit go in as parameters (`glucoseBandParams`).
|
||
const offenders: string[] = [];
|
||
for (const file of bundles) {
|
||
const entries: Array<[string, string]> = [];
|
||
flattenStrings(
|
||
JSON.parse(readFileSync(join(MESSAGES, file), "utf8")),
|
||
"",
|
||
entries,
|
||
);
|
||
for (const [key, value] of entries) {
|
||
if (key in GLUCOSE_UNIT_LITERAL_ALLOWLIST) continue;
|
||
if (GLUCOSE_UNIT_LITERAL.test(value)) offenders.push(`${file}: ${key}`);
|
||
}
|
||
}
|
||
expect(offenders).toEqual([]);
|
||
});
|
||
|
||
it("every allowlisted key still exists and still names a unit", () => {
|
||
// An entry whose string was reworded or removed is a stale exemption.
|
||
const en: Array<[string, string]> = [];
|
||
flattenStrings(
|
||
JSON.parse(readFileSync(join(MESSAGES, "en.json"), "utf8")),
|
||
"",
|
||
en,
|
||
);
|
||
const byKey = new Map(en);
|
||
const stale = Object.keys(GLUCOSE_UNIT_LITERAL_ALLOWLIST).filter(
|
||
(key) => !GLUCOSE_UNIT_LITERAL.test(byKey.get(key) ?? ""),
|
||
);
|
||
expect(stale).toEqual([]);
|
||
});
|
||
});
|