mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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
158 lines
5.3 KiB
TypeScript
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");
|
|
});
|
|
});
|