Files
HealthLog/src/lib/labs/__tests__/analyte-match.test.ts
T
BernhardBuckel 52ea3b9c2f feat(labs): link a marker to its analyte for the unit conversion
Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.

matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.

The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.

The key is returned on the biomarker API and in the OpenAPI schema.

Refs #1095
2026-10-11 20:04:43 +02:00

165 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { matchAnalyteKey, analyteNameIndex } from "../analyte-match";
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog";
import { analyteAcceptsUnit } from "../unit-conversion/convert";
import {
allAnalyteConversions,
analyteConversionFor,
} from "../unit-conversion/registry";
const match = (name: string, unit: string) => matchAnalyteKey({ name, unit });
describe("the catalog's own names, in every language", () => {
it.each([
["Fasting glucose", "mg/dL", "fasting-glucose"],
["Nüchternglukose", "mg/dL", "fasting-glucose"],
["Glucosa en ayunas", "mg/dL", "fasting-glucose"],
["Glycémie à jeun", "mmol/L", "fasting-glucose"],
["Glicemia a digiuno", "mg/dL", "fasting-glucose"],
["공복혈당", "mg/dL", "fasting-glucose"],
["Glukoza na czczo", "mg/dL", "fasting-glucose"],
["HbA1c", "%", "hba1c"],
["Total cholesterol", "mg/dL", "total-cholesterol"],
["Gesamtcholesterin", "mg/dL", "total-cholesterol"],
["Cholestérol total", "mmol/L", "total-cholesterol"],
["LDL cholesterol", "mg/dL", "ldl"],
["LDL-Cholesterin", "mg/dL", "total-cholesterol"],
["Colesterolo HDL", "mg/dL", "total-cholesterol"],
["Vitamin D (25-OH)", "ng/mL", "vitamin-d"],
["Créatinine", "mg/dL", "creatinine"],
["Kreatynina", "µmol/L", "creatinine"],
])("%s (%s) is %s", (name, unit, key) => {
// LDL and HDL are aliases of cholesterol: the registry reports one key.
const expected = analyteConversionFor(key)!.key;
expect(match(name, unit)).toBe(expected);
});
it("recognises a seed typed without the app's gloss", () => {
expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d");
});
});
describe("typed names", () => {
it.each([
["Glucose", "mg/dL", "fasting-glucose"],
["Blutzucker", "mg/dL", "fasting-glucose"],
["blood sugar", "mmol/L", "fasting-glucose"],
["Cholesterin", "mg/dL", "total-cholesterol"],
["Serum Creatinine", "mg/dL", "creatinine"],
["Urea", "mmol/L", "urea"],
["Harnstoff", "mg/dL", "urea"],
["BUN", "mg/dL", "blood-urea-nitrogen"],
["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"],
["Harnsäure", "µmol/L", "uric-acid"],
["Uric acid", "mg/dL", "uric-acid"],
["Kalzium", "mmol/L", "calcium-total"],
["Calcium", "mg/dL", "calcium-total"],
["Gesamtbilirubin", "µmol/L", "bilirubin-total"],
["Bilirubin total", "mg/dL", "bilirubin-total"],
])("%s (%s) is %s", (name, unit, key) => {
expect(match(name, unit)).toBe(key);
});
it("folds case, accents, hyphens and spacing the same on both sides", () => {
expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid");
expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key);
expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("a name that is not the whole analyte name stays unlinked", () => {
it.each([
["Calcium (ionisiert)", "mmol/L"],
["Ionised calcium", "mmol/L"],
["Calcium ionisiert", "mmol/L"],
["Urea nitrogen ratio", "mg/dL"],
["Glucose (urine)", "mg/dL"],
["Glucose tolerance", "mg/dL"],
["Non-HDL cholesterol", "mg/dL"],
["Cholesterol ratio", "mg/dL"],
["Direct bilirubin", "mg/dL"],
["Free thyroxine", "pmol/L"],
["", "mg/dL"],
])("%s", (name, unit) => {
expect(match(name, unit)).toBeNull();
});
it("keeps urea and blood urea nitrogen apart", () => {
expect(match("Urea", "mmol/L")).toBe("urea");
expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("the marker's unit must be one the analyte reaches", () => {
it("links a unit the entry lists and one that differs by a power of ten", () => {
expect(match("Glucose", "mg/dL")).toBe("fasting-glucose");
expect(match("Glucose", "mmol/L")).toBe("fasting-glucose");
expect(match("Glucose", "g/L")).toBe("fasting-glucose");
expect(match("Creatinine", "mmol/L")).toBe("creatinine");
});
it("leaves a unit the registry cannot reach unlinked", () => {
expect(match("Glucose", "mEq/L")).toBeNull();
expect(match("Glucose", "%")).toBeNull();
expect(match("HbA1c", "mg/dL")).toBeNull();
expect(match("Calcium", "furlongs")).toBeNull();
});
it("reads a spelling of the unit through the normaliser", () => {
expect(match("Glucose", "MG/DL")).toBe("fasting-glucose");
expect(match("Creatinine", "umol/l")).toBe("creatinine");
});
});
describe("the alias list, as a guard", () => {
it("names only analytes the registry knows", () => {
for (const { key } of ANALYTE_ALIASES) {
expect(analyteConversionFor(key), key).not.toBeNull();
}
});
it("writes every name already folded, so what is read is what is matched", () => {
for (const { key, names } of ANALYTE_ALIASES) {
for (const name of names) {
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
}
}
});
it("has no name that two analytes claim", () => {
expect(analyteNameIndex().ambiguous).toEqual([]);
});
it("gives every registry entry at least one name to be found by", () => {
const { index } = analyteNameIndex();
const reached = new Set(index.values());
for (const entry of allAnalyteConversions()) {
expect(reached.has(entry.key), entry.key).toBe(true);
}
});
it("covers every catalog seed the registry knows, in all seven languages", () => {
const { index } = analyteNameIndex();
const seeds = BIOMARKER_CATALOG.filter((seed) =>
analyteConversionFor(seed.slug),
);
expect(seeds.length).toBeGreaterThanOrEqual(6);
for (const seed of seeds) {
const key = analyteConversionFor(seed.slug)!.key;
const names = [...index.entries()].filter(([, k]) => k === key);
expect(names.length, seed.slug).toBeGreaterThan(0);
}
});
it("links each seed at its own catalog unit", () => {
for (const seed of BIOMARKER_CATALOG) {
const entry = analyteConversionFor(seed.slug);
if (!entry) continue;
expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true);
}
});
});