diff --git a/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts b/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts new file mode 100644 index 000000000..7ba526323 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + localisedQualitativeIndex, + qualitativeValueConcept, +} from "@/lib/fhir/lab-qualitative"; +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; + +const SNOMED = "http://snomed.info/sct"; + +function codeOf(valueText: string): string | undefined { + const concept = qualitativeValueConcept(valueText); + return concept.coding?.[0]?.code; +} + +/** + * The lab form offers "negative" and "positive" as quick picks and writes the + * LABEL it rendered, so the column holds the bundle's own string in the + * language the reading was recorded in. Matched against a transcribed + * English/German list, four of the six shipped locales exported a result the + * receiving system could not read. + */ +describe("qualitativeValueConcept — the quick-pick label in every locale", () => { + it.each([ + ["négatif", "260385009"], + ["negativo", "260385009"], + ["ujemny", "260385009"], + ["positif", "10828004"], + ["positivo", "10828004"], + ["dodatni", "10828004"], + ])("codes %s as SNOMED %s", (valueText, code) => { + expect(codeOf(valueText)).toBe(code); + }); + + it("derives from the bundles rather than from a transcribed list", () => { + for (const locale of locales) { + const negative = resolveKey( + allMessages[locale], + "labs.form.qualNegative", + ); + const positive = resolveKey( + allMessages[locale], + "labs.form.qualPositive", + ); + expect(negative, `${locale} has no negative label`).toBeTruthy(); + expect(positive, `${locale} has no positive label`).toBeTruthy(); + expect(codeOf(negative as string), `${locale}: ${negative}`).toBe( + "260385009", + ); + expect(codeOf(positive as string), `${locale}: ${positive}`).toBe( + "10828004", + ); + } + }); + + it("tolerates a term typed without its accents", () => { + expect(codeOf("Negatif")).toBe("260385009"); + expect(codeOf("NÉGATIF")).toBe("260385009"); + }); + + it("keeps the raw text on the concept whatever the language", () => { + expect(qualitativeValueConcept("négatif")).toEqual({ + coding: [{ system: SNOMED, code: "260385009", display: "Negative" }], + text: "négatif", + }); + }); +}); + +describe("qualitativeValueConcept — what stays text-only, and why", () => { + it("leaves borderline uncoded in every locale", () => { + // The candidate "Borderline" qualifier concept was never confidently + // verified, so `labs.form.qualBorderline` is read by nothing. This pins + // that the derivation did not sweep it up by accident. + for (const locale of locales) { + const label = resolveKey(allMessages[locale], "labs.form.qualBorderline"); + expect(label, `${locale} has no borderline label`).toBeTruthy(); + expect( + qualitativeValueConcept(label as string).coding, + `${locale}: ${label}`, + ).toBeUndefined(); + } + }); + + it("leaves the detected / not-detected pair uncoded outside English and German", () => { + // The honest limit of this change, written down rather than discovered + // later: the app ships no string for these, they are prose a lab printed, + // and inventing the four other languages would be inventing clinical + // vocabulary nobody here can check. + expect(codeOf("nicht nachweisbar")).toBe("260415000"); + expect(codeOf("not detected")).toBe("260415000"); + expect(codeOf("non détecté")).toBeUndefined(); + expect(codeOf("niewykrywalny")).toBeUndefined(); + expect(qualitativeValueConcept("non détecté")).toEqual({ + text: "non détecté", + }); + }); + + it("leaves free prose uncoded", () => { + expect(qualitativeValueConcept("siehe Befund")).toEqual({ + text: "siehe Befund", + }); + }); +}); + +describe("qualitativeValueConcept — the existing English and German terms", () => { + it.each([ + ["negativ", "260385009"], + ["negative", "260385009"], + ["Negativ", "260385009"], + ["neg", "260385009"], + ["positiv", "10828004"], + ["positive", "10828004"], + ["pos", "10828004"], + ["nachweisbar", "260373001"], + ["detected", "260373001"], + ])("still codes %s as SNOMED %s", (valueText, code) => { + expect(codeOf(valueText)).toBe(code); + }); +}); + +describe("the derived qualitative index", () => { + it("is not empty, so a matcher that matches nothing cannot pass", () => { + expect(Object.keys(localisedQualitativeIndex()).length).toBeGreaterThan(3); + }); + + it("never puts one word on both arms", () => { + // Two locales spelling a term the same way ("negativo" in Spanish and + // Italian) is expected and folds onto one row. The same word meaning both + // negative and positive would be a bundle bug. + const negatives = new Set(); + const positives = new Set(); + for (const [word, concept] of Object.entries(localisedQualitativeIndex())) { + (concept.code === "260385009" ? negatives : positives).add(word); + } + for (const word of negatives) expect(positives.has(word)).toBe(false); + }); +}); diff --git a/src/lib/fhir/lab-qualitative.ts b/src/lib/fhir/lab-qualitative.ts index fb614e39c..7b5448ddc 100644 --- a/src/lib/fhir/lab-qualitative.ts +++ b/src/lib/fhir/lab-qualitative.ts @@ -3,29 +3,52 @@ * * A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar") * has no number, so its FHIR Observation carries a `valueCodeableConcept` - * instead of a `valueQuantity`. This module maps the common German / English - * result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value - * concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is - * added only when the term resolves CONFIDENTLY. When in doubt the result stays - * text-only — never a fabricated code — mirroring the conservative stance of - * the `lab-loinc` and `illness-snomed` mappers. + * instead of a `valueQuantity`. This module maps common result terms onto a + * small set of WELL-ESTABLISHED SNOMED CT qualifier-value concepts. The raw + * recorded text ALWAYS rides `.text`; a coded `coding` is added only when the + * term resolves CONFIDENTLY. When in doubt the result stays text-only — never + * a fabricated code — mirroring the conservative stance of the `lab-loinc` and + * `illness-snomed` mappers. * * Concepts (SNOMED CT International, qualifier value, REFERENCED not * redistributed per the licence): - * - 260385009 "Negative" — negativ / negative - * - 10828004 "Positive" — positiv / positive + * - 260385009 "Negative" — every locale's `labs.form.qualNegative` + * - 10828004 "Positive" — every locale's `labs.form.qualPositive` * - 260415000 "Not detected" — nicht nachweisbar / not detected * - 260373001 "Detected" — nachweisbar / detected * + * The negative and positive arms are DERIVED from `messages/.json`. + * The lab form offers those two as quick picks and writes the label it + * rendered, so what lands in the column is the bundle's own string — + * "négatif", "negativo", "ujemny" — and matching it against a transcribed + * English/German list meant four of the six shipped locales recorded a result + * the export then could not code. Deriving means a new locale is coded the day + * its bundle lands, with no edit here. + * + * The detected / not-detected arm is NOT derived, because the app ships no + * string for it: those are terms a LAB printed and a person copied in. It + * stays the hand-written English and German pair it has always been, and the + * honest cost is that "non détecté" or "niewykrywalny" stay text-only. Coding + * them would mean inventing clinical vocabulary in four languages nobody here + * can check, and this module's own rule is that an unverified code is worse + * than an honest `.text`. When those terms need coding, they need a quick pick + * in the form and a bundle key — the same route the negative and positive arms + * took. + * * Borderline / grenzwertig is DELIBERATELY left text-only: the candidate - * "Borderline" qualifier concept could not be confidently verified, and an - * unverified code is worse than an honest `.text`. Any unrecognised term + * "Borderline" qualifier concept could not be confidently verified. Its bundle + * key is therefore read by nothing here, on purpose. Any unrecognised term * likewise stays text-only. * + * SERVER-ONLY: the derived index reads every shipped message bundle. Its one + * caller is the FHIR exporter, which runs on the server. + * * Systems: SNOMED CT — http://snomed.info/sct */ import type { FhirCodeableConcept } from "@/lib/fhir/types"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; /** * SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to @@ -39,29 +62,76 @@ interface QualitativeConcept { display: string; } +const NEGATIVE: QualitativeConcept = { code: "260385009", display: "Negative" }; +const POSITIVE: QualitativeConcept = { code: "10828004", display: "Positive" }; + /** - * Normalised result term → SNOMED qualifier-value concept. The left side is the - * lower-cased, whitespace-collapsed recorded text. Only confidently-known terms - * appear; everything else falls through to text-only. + * Hand-written result terms → SNOMED qualifier-value concept. The left side is + * the folded recorded text. + * + * What belongs here is what the app ships no string for: the clinical + * shorthand a person types ("neg", "pos") and the detected / not-detected pair + * a lab prints. The negative and positive WORDS are not transcribed here — see + * the derived index below. */ -const QUALITATIVE_SNOMED: Record = { - // Negative-like - negativ: { code: "260385009", display: "Negative" }, - negative: { code: "260385009", display: "Negative" }, - neg: { code: "260385009", display: "Negative" }, +const HAND_WRITTEN_SNOMED: Record = { + neg: NEGATIVE, + pos: POSITIVE, "nicht nachweisbar": { code: "260415000", display: "Not detected" }, "not detected": { code: "260415000", display: "Not detected" }, - // Positive-like - positiv: { code: "10828004", display: "Positive" }, - positive: { code: "10828004", display: "Positive" }, - pos: { code: "10828004", display: "Positive" }, nachweisbar: { code: "260373001", display: "Detected" }, detected: { code: "260373001", display: "Detected" }, }; -/** Collapse a recorded qualitative term to a lookup key. */ +/** + * The quick-pick labels, in every shipped locale, read out of the bundles. + * + * The form writes the label it rendered, so this index and the write side + * cannot drift: whatever `labs.form.qualNegative` says in a language is + * exactly what a reading recorded in that language holds. `qualBorderline` is + * deliberately absent — see the module comment. + */ +function buildLocalisedIndex(): Record { + const index: Record = {}; + const arms: [string, QualitativeConcept][] = [ + ["labs.form.qualNegative", NEGATIVE], + ["labs.form.qualPositive", POSITIVE], + ]; + for (const [key, concept] of arms) { + for (const label of localisedValues(key)) { + const folded = foldForMatch(label); + // A folded label already claimed by the OTHER arm would mean a bundle + // says the same word means both negative and positive. First wins so a + // typo cannot take the export down at boot; the guard suite fails on it. + if (!folded || index[folded]) continue; + index[folded] = concept; + } + } + return index; +} + +const LOCALISED_SNOMED = buildLocalisedIndex(); + +/** + * Test seam: the guard suite asserts the derived index covers every shipped + * locale and disagrees with no hand-written term. Production reads the + * constant. + */ +export function localisedQualitativeIndex(): Readonly< + Record +> { + return LOCALISED_SNOMED; +} + +/** + * Collapse a recorded qualitative term to a lookup key. + * + * Folds case, accents and separators, so a reading typed "Negatif" on a + * keyboard without accents meets the bundle's "négatif", and a stored + * "negativ" resolves exactly as it did before the fold widened. + */ function normaliseQualitativeKey(text: string): string { - return text.trim().toLowerCase().replace(/\s+/g, " "); + return foldForMatch(text); } /** @@ -73,7 +143,8 @@ function normaliseQualitativeKey(text: string): string { export function qualitativeValueConcept( valueText: string, ): FhirCodeableConcept { - const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)]; + const key = normaliseQualitativeKey(valueText); + const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key]; if (!concept) { return { text: valueText }; }