Files
HealthLog/src/components/labs/__tests__/ocr-row-editor.test.tsx
T
BernhardBuckel d3916036c2 fix(labs): refuse a reading whose unit differs from its marker's
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.

- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
  the reading's unit in meta, and writes nothing. The unit is compared only
  when one was sent: on the biomarkerId path it may be omitted, and the
  marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
  reads partial or failed instead of a clean save, and the toast names the
  analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
  shows a calm badge on a row in another unit, starts it unticked like a
  duplicate, and treats it as a blocking error on the unit field if it is
  ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
  prefix, the case of the litre, and a unit printed in capitals for a short
  list where capitals cannot mean another unit. Case is otherwise significant,
  so mIU/L and MIU/L, or g/L and G/L, are never merged.

The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.

Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.

Fixes #1094
2026-10-03 15:44:51 +02:00

158 lines
5.3 KiB
TypeScript

import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import type { OcrReviewRow } from "../ocr-review-types";
import { OcrRowEditor } from "../ocr-row-editor";
const baseRow: OcrReviewRow = {
key: "LDL-0",
analyte: "LDL",
value: 2.4,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 3,
referenceText: null,
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
biomarkerMatch: "existing",
markerUnit: "mmol/L",
duplicateOf: null,
confirmed: true,
};
function render(
errors: Parameters<typeof OcrRowEditor>[0]["errors"],
locale: "en" | "de" = "en",
row: OcrReviewRow = baseRow,
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrRowEditor row={row} onChange={() => {}} errors={errors} />
</I18nProvider>,
);
}
describe("<OcrRowEditor> required-field marking", () => {
it("shows no error state before a save was attempted", () => {
const html = render(undefined);
expect(html).not.toContain('aria-invalid="true"');
expect(html).not.toContain('role="alert"');
});
it("marks the missing date, states why, and links the message to the field", () => {
const html = render({ date: true });
const invalid = html.match(/<input[^>]*aria-invalid="true"[^>]*>/g) ?? [];
expect(invalid).toHaveLength(1);
const describedBy = invalid[0]?.match(/aria-describedby="([^"]+)"/)?.[1];
expect(describedBy).toBeTruthy();
expect(html).toContain(`id="${describedBy}"`);
expect(html).toContain("Add the sample date.");
expect(html).toContain('role="alert"');
// The bordered wrapper is what the eye sees.
expect(html).toContain("border-destructive");
});
it("marks only the fields that are wrong", () => {
const html = render({ unit: true });
expect(html.match(/aria-invalid="true"/g)).toHaveLength(1);
expect(html).toContain("Add a unit.");
expect(html).not.toContain("Add the sample date.");
});
it("marks a blank qualitative result", () => {
const html = render({ valueText: true }, "en", {
...baseRow,
value: null,
valueText: "",
unit: null,
});
expect(html).toContain("Add a result.");
expect(html.match(/aria-invalid="true"/g)).toHaveLength(1);
});
it("carries a visible required marker on the fields a row needs", () => {
const html = render(undefined);
// Value, unit and date on a numeric row.
expect(html.match(/text-destructive"[^>]*> \*</g)).toHaveLength(3);
});
it("speaks German too", () => {
expect(render({ date: true }, "de")).toContain("Bitte ein Datum angeben.");
});
});
describe("<OcrRowEditor> layout", () => {
it("lays out from the width of its container, not the window", () => {
const html = render(undefined);
expect(html).toContain('class="@container"');
// Two columns only once the container is wide; stacked below that.
expect(html).toContain("@3xl:grid-cols-");
});
it("sizes the field grid from its own container, never from the window", () => {
const numeric = render(undefined);
expect(numeric).toContain("@md:grid-cols-3");
expect(numeric).not.toMatch(/(?<![@\w-])sm:grid-cols/);
// A qualitative result spans the grid's columns; the span has to follow
// the same query as the columns, or it would open a column of its own.
const qualitative = render(undefined, "en", {
...baseRow,
value: null,
valueText: "negative",
unit: null,
});
expect(qualitative).toContain("@md:col-span-3");
expect(qualitative).not.toMatch(/(?<![@\w-])sm:col-span/);
});
it("keeps every field of the row, however the columns fall", () => {
const html = render(undefined);
for (const id of ["-val", "-unit", "-date", "-lo", "-hi", "-refText"]) {
expect(html, `field ${id}`).toContain(`${id}"`);
}
});
});
describe("<OcrRowEditor> unit differs from the marker's", () => {
const mismatched: OcrReviewRow = {
...baseRow,
takenAt: "2026-06-10",
unit: "mg/dL",
markerUnit: "mmol/L",
};
it("says so before any save attempt, in calm wording", () => {
const html = render(undefined, "en", mismatched);
expect(html).toContain("Tracked in mmol/L; this report says mg/dL");
expect(html).not.toContain('aria-invalid="true"');
expect(html).not.toContain('role="alert"');
});
it("marks the unit field and names the marker's unit once a save was refused", () => {
const html = render({ unitMismatch: true }, "en", mismatched);
const invalid = html.match(/<input[^>]*aria-invalid="true"[^>]*>/g) ?? [];
expect(invalid).toHaveLength(1);
expect(invalid[0]).toMatch(/id="[^"]*-unit"/);
expect(html).toContain("This marker is tracked in mmol/L.");
});
it("shows nothing when the units agree, however they are spelled", () => {
const html = render(undefined, "en", { ...mismatched, unit: "mmol/l" });
expect(html).not.toContain("Tracked in");
});
it("shows nothing for a new marker, which adopts the row's unit", () => {
const html = render(undefined, "en", { ...mismatched, markerUnit: null });
expect(html).not.toContain("Tracked in");
});
it("speaks German too", () => {
const html = render(undefined, "de", mismatched);
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
});
});