mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Add first-class qualitative lab findings (positive / negative / borderline serology) alongside the existing numeric path. A LabResult is now EITHER a numeric reading (value + unit, optional reference bounds) OR a qualitative one (valueText, e.g. "negativ"), never both, never neither. - schema: make `value` nullable, add `value_text`; migration 0181 is additive (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched. - validation: numeric-XOR-qualitative refine on create + update; unit/bounds apply to the numeric path only; valueText capped at 120 chars. The single resource PUT rejects a cross-type edit. - serialise: carry valueText through both DTOs; a qualitative row reports the neutral `unknown` range verdict (no number to classify). - FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier value for confidently-known terms (Negative 260385009, Positive 10828004, Not detected 260415000, Detected 260373001), text-only for borderline / grenzwertig and any unrecognised term. The raw text always rides .text. - UI: a numeric/qualitative toggle in the lab form; list, history, detail, chart, sparkline, and the doctor-report PDF all render qualitative readings as their text and never plot or divide on a null value. - i18n: new labs.form keys across all six locales. - openapi: response carries a nullable value + valueText; regenerated.
280 lines
7.6 KiB
TypeScript
280 lines
7.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
classifyReferenceRange,
|
|
createLabResultSchema,
|
|
listLabResultsSchema,
|
|
updateLabResultSchema,
|
|
} from "../labs";
|
|
|
|
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
const RECENT = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
|
|
describe("createLabResultSchema", () => {
|
|
it("accepts a minimal valid reading", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "HbA1c",
|
|
value: 5.4,
|
|
unit: "%",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("normalises a backdatable takenAt to a Date", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: 110,
|
|
unit: "mg/dL",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.takenAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects a future takenAt", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: 110,
|
|
unit: "mg/dL",
|
|
takenAt: FUTURE,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects an empty analyte", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: " ",
|
|
value: 1,
|
|
unit: "%",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a transposed reference range (low > high)", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "Ferritin",
|
|
value: 50,
|
|
unit: "ng/mL",
|
|
referenceLow: 300,
|
|
referenceHigh: 30,
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("accepts a single (upper-only) reference bound", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: 110,
|
|
unit: "mg/dL",
|
|
referenceHigh: 116,
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
biomarkerId: "bm_123",
|
|
value: 110,
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects an entry with neither biomarkerId nor analyte", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
value: 110,
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a free-text entry missing the unit", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: 110,
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("normalises an empty-string panel to undefined", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "TSH",
|
|
value: 2,
|
|
unit: "mIU/L",
|
|
panel: "",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.panel).toBeUndefined();
|
|
});
|
|
|
|
it("rejects a non-finite value", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: Number.POSITIVE_INFINITY,
|
|
unit: "mg/dL",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
// ── qualitative readings (v1.18.9) ────────────────────────────────
|
|
it("accepts a qualitative reading (valueText, no numeric value/unit)", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "Hepatitis Bs-Antigen",
|
|
valueText: "negativ",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a qualitative reading against a catalog biomarker", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
biomarkerId: "bm_123",
|
|
valueText: "positiv",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a reading carrying BOTH value and valueText", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
value: 110,
|
|
unit: "mg/dL",
|
|
valueText: "negativ",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a reading carrying NEITHER value nor valueText", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "LDL",
|
|
unit: "mg/dL",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects an empty qualitative valueText", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "Serology",
|
|
valueText: " ",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects an over-long valueText (> 120 chars)", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "Serology",
|
|
valueText: "x".repeat(121),
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("does not require a unit for a qualitative reading", () => {
|
|
const r = createLabResultSchema.safeParse({
|
|
analyte: "Serology",
|
|
valueText: "negativ",
|
|
takenAt: RECENT,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("updateLabResultSchema", () => {
|
|
it("accepts an explicit null to clear panel + note", () => {
|
|
const r = updateLabResultSchema.safeParse({ panel: null, note: null });
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.panel).toBeNull();
|
|
expect(r.data.note).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("accepts a partial value-only edit", () => {
|
|
const r = updateLabResultSchema.safeParse({ value: 5.6 });
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a partial valueText-only edit (v1.18.9)", () => {
|
|
const r = updateLabResultSchema.safeParse({ valueText: "positiv" });
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects an edit carrying both value and valueText", () => {
|
|
const r = updateLabResultSchema.safeParse({
|
|
value: 5.6,
|
|
valueText: "negativ",
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("still enforces the range order when both bounds are supplied", () => {
|
|
const r = updateLabResultSchema.safeParse({
|
|
referenceLow: 10,
|
|
referenceHigh: 1,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("listLabResultsSchema", () => {
|
|
it("defaults limit/offset/sortDir", () => {
|
|
const r = listLabResultsSchema.safeParse({});
|
|
expect(r.success).toBe(true);
|
|
if (r.success) {
|
|
expect(r.data.limit).toBe(100);
|
|
expect(r.data.offset).toBe(0);
|
|
expect(r.data.sortDir).toBe("desc");
|
|
}
|
|
});
|
|
|
|
it("caps limit at 500", () => {
|
|
const r = listLabResultsSchema.safeParse({ limit: "9999" });
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("classifyReferenceRange", () => {
|
|
it("returns unknown when no bounds are given", () => {
|
|
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
|
|
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
|
|
});
|
|
|
|
it("returns in-range inside both bounds", () => {
|
|
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
|
|
});
|
|
|
|
it("treats the bounds as inclusive", () => {
|
|
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
|
|
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
|
|
});
|
|
|
|
it("returns below when under the low bound", () => {
|
|
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
|
|
});
|
|
|
|
it("returns above when over the high bound", () => {
|
|
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
|
|
});
|
|
|
|
it("classifies against a single upper bound", () => {
|
|
expect(classifyReferenceRange(120, null, 116)).toBe("above");
|
|
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
|
|
});
|
|
|
|
it("classifies against a single lower bound", () => {
|
|
expect(classifyReferenceRange(8, 10, null)).toBe("below");
|
|
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
|
|
});
|
|
});
|