Files
HealthLog/src/lib/__tests__/glucose.test.ts
T
Marc-André Bombeck 75361d0666 fix(glucose): state band edges in the reader's glucose unit
The time-in-range labels in the doctor report, the target caption on
the glucose panel, the glucose page description and the fasting
explainer printed fixed mg/dL figures, so a reader on mmol/L saw
"Low (< 70 mg/dL)" beside values in mmol/L.

The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`,
`GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints,
the analytics engine counts its bands from the same constants, and
every sentence takes the band edges and unit as parameters through
`glucoseBandParams`, formatted with the reader's number format.

The unit-preference guard now also reads the locale bundles: a string
that names mg/dL or mmol/L fails unless it is allowlisted with a
reason.

Refs #1067
2026-10-01 22:07:07 +02:00

206 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
glucoseContextBucket,
groupByGlucoseContext,
GLUCOSE_CONTEXT_UNSPECIFIED,
GLUCOSE_FASTING_CUT_POINTS,
GLUCOSE_TIR_CUT_POINTS,
glucoseBandParams,
} from "../glucose";
import { makeFormatters } from "../format-locale";
describe("glucose conversion", () => {
it("converts mg/dL to mmol/L with 1 decimal", () => {
expect(mgdlToMmol(100)).toBe(5.5);
expect(mgdlToMmol(126)).toBeCloseTo(7.0, 1); // diabetes threshold
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
});
it("convertGlucose dispatches to the right unit", () => {
expect(convertGlucose(100, "mg/dL")).toBe(100);
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
});
it("resolveGlucoseUnit defaults to mg/dL", () => {
expect(resolveGlucoseUnit(null)).toBe("mg/dL");
expect(resolveGlucoseUnit(undefined)).toBe("mg/dL");
expect(resolveGlucoseUnit("mmol/L")).toBe("mmol/L");
expect(resolveGlucoseUnit("mg/dL")).toBe("mg/dL");
expect(resolveGlucoseUnit("random-garbage")).toBe("mg/dL");
});
it("thresholdMetricForContext maps every context", () => {
expect(thresholdMetricForContext("FASTING")).toBe("BLOOD_GLUCOSE_FASTING");
expect(thresholdMetricForContext("POSTPRANDIAL")).toBe(
"BLOOD_GLUCOSE_POSTPRANDIAL",
);
expect(thresholdMetricForContext("RANDOM")).toBe("BLOOD_GLUCOSE_RANDOM");
expect(thresholdMetricForContext("BEDTIME")).toBe("BLOOD_GLUCOSE_BEDTIME");
});
// Display-unit logic guard for the targets/dashboard surfaces:
// canonical mg/dL stays integer; mmol/L stays 1-decimal; ranges convert too.
it("display unit conversion preserves integer/decimal precision", () => {
const fastingMin = 70; // mg/dL
const fastingMax = 99;
const ada126 = 126; // diabetes threshold
expect(convertGlucose(fastingMin, "mg/dL")).toBe(70);
expect(convertGlucose(fastingMax, "mg/dL")).toBe(99);
expect(convertGlucose(ada126, "mg/dL")).toBe(126);
expect(convertGlucose(fastingMin, "mmol/L")).toBe(3.9);
expect(convertGlucose(fastingMax, "mmol/L")).toBe(5.5);
expect(convertGlucose(ada126, "mmol/L")).toBe(7);
});
// The target-editor write path: a value the user typed in their
// display unit must persist as canonical mg/dL. Before the fix the
// editor saved the mmol/L number verbatim (a 5.5 mmol/L target stored
// as 5.5 mg/dL, or rejected outright by the 40–400 mg/dL bounds).
it("converts a display-unit value back to canonical mg/dL", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126); // ADA diabetes threshold
expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold
});
// v1.16.16 — "one number, one engine" coherence. The wire series DTO, the
// CSV export, and the FHIR export each convert a stored mg/dL reading to the
// user's display unit through the SAME `convertGlucose` helper, so the three
// surfaces emit an identical value + unit. This guards against any surface
// drifting back to a render-time or hard-coded conversion.
it("emits a coherent value+unit across series / CSV / FHIR for mmol/L", () => {
const storedMgdl = 126; // ADA diabetes threshold
const unit = resolveGlucoseUnit("mmol/L");
// Series route: point value = convertGlucose(row.value, unit).
const seriesValue = convertGlucose(storedMgdl, unit);
// CSV export: BLOOD_GLUCOSE row value = convertGlucose(m.value, unit).
const csvValue = convertGlucose(storedMgdl, unit);
// FHIR export: valueQuantity.value = convertGlucose(stat.latest, unit).
const fhirValue = convertGlucose(storedMgdl, unit);
expect(unit).toBe("mmol/L");
expect(seriesValue).toBe(7);
expect(csvValue).toBe(seriesValue);
expect(fhirValue).toBe(seriesValue);
});
it("emits a coherent value+unit across series / CSV / FHIR for mg/dL", () => {
const storedMgdl = 100;
const unit = resolveGlucoseUnit("mg/dL");
const seriesValue = convertGlucose(storedMgdl, unit);
const csvValue = convertGlucose(storedMgdl, unit);
const fhirValue = convertGlucose(storedMgdl, unit);
expect(unit).toBe("mg/dL");
expect(seriesValue).toBe(100);
expect(csvValue).toBe(seriesValue);
expect(fhirValue).toBe(seriesValue);
});
it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => {
// mg/dL display unit is already canonical — only rounds.
expect(toCanonicalMgdl(126, "mg/dL")).toBe(126);
expect(toCanonicalMgdl(99.4, "mg/dL")).toBe(99);
// mmol/L display unit converts back; the round-trip is stable to the
// 1-decimal mmol/L the UI shows (≤1 mg/dL drift is unavoidable).
expect(toCanonicalMgdl(7.0, "mmol/L")).toBe(126);
expect(toCanonicalMgdl(mgdlToMmol(126), "mmol/L")).toBeCloseTo(126, -0.5);
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
describe("glucose context buckets (#943)", () => {
it("files NULL, blank and unknown values under the untagged bucket", () => {
expect(glucoseContextBucket(null)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(undefined)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(" ")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("BEFORE_MEAL")).toBe(
GLUCOSE_CONTEXT_UNSPECIFIED,
);
});
it("keeps the named contexts named, case- and whitespace-tolerant", () => {
expect(glucoseContextBucket("FASTING")).toBe("FASTING");
expect(glucoseContextBucket(" bedtime ")).toBe("BEDTIME");
});
it("groups in canonical order with the untagged bucket last", () => {
const rows = [
{ ctx: null, v: 1 },
{ ctx: "BEDTIME", v: 2 },
{ ctx: "FASTING", v: 3 },
{ ctx: "", v: 4 },
];
const grouped = groupByGlucoseContext(rows, (row) => row.ctx);
expect(grouped.map(([bucket]) => bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
// Input order survives inside a bucket, so a caller reading "latest" off
// the head keeps reading the same row.
expect(grouped[2][1].map((row) => row.v)).toEqual([1, 4]);
});
it("returns nothing for no rows", () => {
expect(groupByGlucoseContext([], () => null)).toEqual([]);
});
it("judges the untagged bucket against the RANDOM threshold", () => {
expect(thresholdMetricForContext(GLUCOSE_CONTEXT_UNSPECIFIED)).toBe(
"BLOOD_GLUCOSE_RANDOM",
);
});
});
describe("glucose band parameters", () => {
const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number;
const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number;
it("states the consensus bands in mg/dL as whole numbers", () => {
expect(glucoseBandParams("mg/dL", en)).toMatchObject({
unit: "mg/dL",
veryLow: "54",
low: "70",
high: "180",
veryHigh: "250",
prediabetesFrom: "100",
prediabetesTo: "125",
diabetesFrom: "126",
});
});
it("states them in mmol/L with the locale's decimal separator", () => {
expect(glucoseBandParams("mmol/L", de)).toMatchObject({
unit: "mmol/L",
veryLow: "3,0",
low: "3,9",
high: "10,0",
veryHigh: "13,9",
prediabetesFrom: "5,6",
prediabetesTo: "6,9",
diabetesFrom: "7,0",
});
});
it("carries SI figures that agree with the conversion", () => {
// The consensus bands convert exactly; the ADA cut-points are within
// one rounding step of it (100 mg/dL is printed as 5.6, converts to 5.5).
for (const point of Object.values(GLUCOSE_TIR_CUT_POINTS)) {
expect(point.mmol).toBe(mgdlToMmol(point.mgdl));
}
for (const point of Object.values(GLUCOSE_FASTING_CUT_POINTS)) {
expect(Math.abs(point.mmol - point.mgdl / 18.0182)).toBeLessThan(0.1);
}
});
});