marmar-context / web / context / test / charts.test.ts
charts.test.ts
Raw
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { SharedDoses, SharedLab } from "../src/api/types";
import { DoseBars, FindingsMix, LabsPanel, LabTrend } from "../src/clinician/charts";
import {
  doseHeadline,
  doseStack,
  dosePercent,
  findingsMix,
  labChange,
  labSeries,
  labsAnswer,
  niceTicks,
  parseLabValue,
  parseRange,
  plotLab,
  rangeLabel,
  smoothPath,
  sumDoses,
  trendWords,
} from "../src/logic/charts";

const lab = (id: string, name: string, value: string, date: string, more: Partial<SharedLab> = {}): SharedLab => ({
  id,
  name,
  value,
  unit: "mL/min/1.73m2",
  range: "Above 60",
  flag: null,
  date,
  addedBy: "MARMAR Health",
  addedAt: `${date}T09:00:00Z`,
  changedAt: `${date}T09:00:00Z`,
  ...more,
});

describe("lab ranges", () => {
  it("reads both ends, with any dash or 'to'", () => {
    expect(parseRange("3.5-5.0")).toEqual({ low: 3.5, high: 5 });
    expect(parseRange("3.5 – 5.0")).toEqual({ low: 3.5, high: 5 });
    expect(parseRange("3.5 — 5.0 mmol/L")).toEqual({ low: 3.5, high: 5 });
    expect(parseRange("3.5 to 5.0")).toEqual({ low: 3.5, high: 5 });
    expect(parseRange("1,000-4,000")).toEqual({ low: 1000, high: 4000 });
    expect(parseRange("-2 - 2")).toEqual({ low: -2, high: 2 });
    expect(parseRange("4.0 - 11.0 (adult)")).toEqual({ low: 4, high: 11 });
  });

  it("reads one open end", () => {
    expect(parseRange("< 5.7")).toEqual({ low: null, high: 5.7 });
    expect(parseRange("<=5.7%")).toEqual({ low: null, high: 5.7 });
    expect(parseRange("≤ 5.7")).toEqual({ low: null, high: 5.7 });
    expect(parseRange("Below 7.0")).toEqual({ low: null, high: 7 });
    expect(parseRange("up to 40")).toEqual({ low: null, high: 40 });
    expect(parseRange("5.7 or less")).toEqual({ low: null, high: 5.7 });
    expect(parseRange(">60")).toEqual({ low: 60, high: null });
    expect(parseRange("≥ 60")).toEqual({ low: 60, high: null });
    expect(parseRange("60+")).toEqual({ low: 60, high: null });
    expect(parseRange("Above 60")).toEqual({ low: 60, high: null });
    expect(parseRange("60 and above")).toEqual({ low: 60, high: null });
    expect(parseRange("Above 60 mL/min/1.73m2")).toEqual({ low: 60, high: null });
  });

  it("reads the backend's one-ended form", () => {
    expect(parseRange("3.5 to")).toEqual({ low: 3.5, high: null });
    expect(parseRange("to 5")).toEqual({ low: null, high: 5 });
    expect(rangeLabel("3.5 to")).toBe("3.5 or above");
    expect(rangeLabel("to 5")).toBe("Up to 5");
    expect(rangeLabel("Above 60")).toBe("Above 60");
  });

  it("draws no band for junk", () => {
    for (const junk of ["", "   ", "Normal", "see report", "N/A", "5.7", "<", "60-", "1-2-3", "5.0-3.5", "3,5-5,0", "90-120/60-80", null, undefined])
      expect(parseRange(junk as string | null)).toBeNull();
  });
});

describe("lab values", () => {
  it("reads plain numbers only", () => {
    expect(parseLabValue("38")).toBe(38);
    expect(parseLabValue(" 7.9 ")).toBe(7.9);
    expect(parseLabValue("1,200")).toBe(1200);
    expect(parseLabValue("−3")).toBe(-3);
    expect(parseLabValue("7.9 %")).toBe(7.9);
    for (const v of ["<0.5", ">90", "Positive", "148/92", "12,5", ""]) expect(parseLabValue(v)).toBeNull();
  });
});

describe("lab series", () => {
  const labs = [
    lab("e3", "eGFR", "38", "2026-10-12", { flag: "low" }),
    lab("h1", "HbA1c", "7.9", "2026-09-15", { unit: "%", range: "Below 7.0", flag: "high" }),
    lab("e1", "eGFR", "58", "2026-04-03"),
    lab("e2", "egfr ", "47", "2026-08-14", { flag: "low" }),
    lab("e0", "eGFR", "61", "2026-02-01", { unit: "ml/min" }),
  ];

  it("groups by name, newest test first, and charts only numbers in the latest unit", () => {
    const [egfr, hba1c] = labSeries(labs);
    expect(egfr.name).toBe("eGFR");
    expect(egfr.results.map((l) => l.id)).toEqual(["e3", "e2", "e1", "e0"]);
    expect(egfr.points.map((p) => p.lab.id)).toEqual(["e1", "e2", "e3"]);
    expect(egfr.trend).toBe(true);
    expect(egfr.range).toEqual({ low: 60, high: null });
    expect(hba1c.trend).toBe(false);
  });

  it("is not a trend when the latest is not a number or every result is on one day", () => {
    expect(labSeries([lab("a", "CRP", "<5", "2026-10-12"), lab("b", "CRP", "12", "2026-09-01")])[0].trend).toBe(false);
    const sameDay = labSeries([lab("a", "K", "4.1", "2026-10-12"), lab("b", "K", "4.4", "2026-10-12", { addedAt: "2026-10-12T08:00:00Z" })])[0];
    expect(sameDay.points).toHaveLength(1);
    expect(sameDay.trend).toBe(false);
  });

  it("says the trend and the flags in words", () => {
    const [egfr] = labSeries(labs);
    const words = trendWords(egfr);
    expect(words).toContain("eGFR, 3 results from 3 Apr 2026 to 12 Oct 2026: it fell from 58 to 38 mL/min/1.73m2.");
    expect(words).toContain("Lowest 38 on 12 Oct 2026, highest 58 on 3 Apr 2026.");
    expect(words).toContain("Range on the record: Above 60.");
    expect(words).toContain("2 of 3 flagged high or low on the record, the latest low.");
    expect(words).not.toMatch(/recommend|should|safe|diagnos/i);
  });

  it("gives the change since the result before, with the record's decimals", () => {
    const [egfr] = labSeries(labs);
    expect(labChange(egfr)).toEqual({ direction: "down", words: "Down 9 since 14 Aug" });
    const a1c = labSeries([lab("a", "HbA1c", "8.1", "2026-10-12", { unit: "%" }), lab("b", "HbA1c", "7.4", "2025-06-01", { unit: "%" })])[0];
    expect(labChange(a1c)).toEqual({ direction: "up", words: "Up 0.7 since 1 Jun 2025" });
  });

  it("answers with what the record flags", () => {
    expect(labsAnswer(labSeries(labs))).toBe("2 of 2 tests flagged on the latest result: eGFR low, HbA1c high.");
    expect(labsAnswer(labSeries([lab("k", "Potassium", "4.6", "2026-10-12")]))).toBe("1 test, none flagged high or low on the latest result.");
    expect(labsAnswer([])).toBe("No lab results shared.");
  });
});

describe("chart geometry", () => {
  const [egfr] = labSeries([lab("e1", "eGFR", "58", "2026-04-03"), lab("e2", "eGFR", "47", "2026-08-14"), lab("e3", "eGFR", "38", "2026-10-12")]);

  it("puts the first and last results at the edges and the range's open end at the top", () => {
    const plot = plotLab(egfr);
    expect(plot.points[0].x).toBe(3);
    expect(plot.points[2].x).toBe(97);
    expect(plot.points[0].y).toBeLessThan(plot.points[2].y);
    for (const p of plot.points) {
      expect(p.y).toBeGreaterThan(0);
      expect(p.y).toBeLessThan(100);
    }
    expect(plot.band?.top).toBe(0);
    expect(plot.band!.bottom).toBeGreaterThan(0);
    expect(plot.band!.bottom).toBeLessThan(plot.points[0].y);
    expect(plot.area.endsWith("Z")).toBe(true);
    expect(plot.ticks.map((t) => t.label)).toEqual(["40", "50", "60"]);
  });

  it("draws no band without a range and copes with one value", () => {
    expect(plotLab({ ...egfr, range: null }).band).toBeNull();
    const one = plotLab({ points: egfr.points.slice(0, 1), range: null });
    expect(one.points[0]).toMatchObject({ x: 50, y: 50 });
    expect(one.line).toBe("");
  });

  it("keeps nice ticks and a line that passes through every point", () => {
    expect(niceTicks(0, 10, 4)).toEqual([0, 2.5, 5, 7.5, 10]);
    expect(niceTicks(3.2, 3.9, 3)).toEqual([3.25, 3.5, 3.75]);
    const d = smoothPath([{ x: 0, y: 10 }, { x: 50, y: 50 }, { x: 100, y: 20 }]);
    expect(d.startsWith("M0 10")).toBe(true);
    expect(d).toContain("50 50");
    expect(d.endsWith("100 20")).toBe(true);
  });
});

describe("doses", () => {
  it("stacks to what was due, with the rest as not yet due or not logged", () => {
    const s = doseStack({ onTime: 10, late: 1, skipped: 1, missed: 0, expected: 14 });
    expect(s.segments.map((x) => [x.key, x.count])).toEqual([["onTime", 10], ["late", 1], ["skipped", 1], ["rest", 2]]);
    expect(s.segments.reduce((a, x) => a + x.count, 0)).toBe(14);
    expect(s.segments.reduce((a, x) => a + x.share, 0)).toBeCloseTo(1);
    expect(s).toMatchObject({ taken: 11, logged: 12, expected: 14, over: false });
  });

  it("handles more logged than due without a bar past 100%", () => {
    const s = doseStack({ onTime: 15, late: 2, skipped: 0, missed: 1, expected: 14 });
    expect(s.over).toBe(true);
    expect(s.segments.some((x) => x.key === "rest")).toBe(false);
    expect(s.segments.reduce((a, x) => a + x.share, 0)).toBeCloseTo(1);
    expect(doseStack({ onTime: 0, late: 0, skipped: 0, missed: 0, expected: 0 }).segments).toEqual([]);
    expect(doseStack({ onTime: -2, late: Number.NaN, skipped: 0, missed: 0, expected: 7 }).segments).toEqual([{ key: "rest", count: 7, share: 1 }]);
  });

  it("rounds the figure down so 100% means every dose", () => {
    expect(dosePercent(12, 14)).toBe(85);
    expect(dosePercent(299, 300)).toBe(99);
    expect(dosePercent(14, 14)).toBe(100);
    expect(dosePercent(16, 14)).toBe(100);
    expect(dosePercent(0, 0)).toBeNull();
  });

  it("keeps this week's sentence and adds up the last 30 days from the medicines", () => {
    const d = {
      week: { taken: 12, expected: 14, onTime: 12 },
      perMedicine: [
        { medicineId: "a", name: "Metformin", schedule: null, changedAt: "", week: { onTime: 12, late: 0, skipped: 2, missed: 0, expected: 14 }, last30: { onTime: 51, late: 3, skipped: 6, missed: 0, expected: 60 } },
        { medicineId: "b", name: "Amlodipine", schedule: null, changedAt: "", week: { onTime: 7, late: 0, skipped: 0, missed: 0, expected: 7 }, last30: { onTime: 28, late: 1, skipped: 1, missed: 0, expected: 30 } },
      ],
    };
    expect(doseHeadline(d, "week")).toMatchObject({ percent: 85, sentence: "12 of 14 doses taken this week, all on time." });
    expect(doseHeadline(d, "last30")).toMatchObject({ percent: 92, taken: 83, expected: 90, sentence: "83 of 90 doses taken in the last 30 days, 79 on time." });
    expect(doseHeadline({ ...d, week: { taken: 0, expected: 0, onTime: 0 } }, "week")).toMatchObject({ percent: null, sentence: "No doses were due this week." });
    expect(doseHeadline({ ...d, week: { taken: 0, expected: 7, onTime: 0 } }, "week").sentence).toBe("0 of 7 doses taken this week.");
    expect(sumDoses([])).toEqual({ onTime: 0, late: 0, skipped: 0, missed: 0, expected: 0 });
  });
});

describe("findings mix", () => {
  it("lists every tier, most pressing first, with unknown tiers as Good to know", () => {
    const mix = findingsMix([{ tier: "this_week" }, { tier: "get_help_now" }, { tier: "this_week" }, { tier: "something_new" }]);
    expect(mix.total).toBe(4);
    expect(mix.tiers.map((t) => [t.label, t.count])).toEqual([
      ["Urgent review", 1],
      ["Review today", 0],
      ["Review this week", 2],
      ["Good to know", 1],
    ]);
    expect(findingsMix([]).tiers.every((t) => t.share === 0)).toBe(true);
  });
});

const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el).replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, "&");

describe("the charts on the page", () => {
  const labs = [
    lab("e3", "eGFR", "38", "2026-10-12", { flag: "low" }),
    lab("e2", "eGFR", "47", "2026-08-14", { flag: "low" }),
    lab("e1", "eGFR", "58", "2026-04-03"),
    lab("k1", "Potassium", "4.6", "2026-10-12", { unit: "mmol/L", range: "3.5-5.0" }),
  ];

  it("says the trend in words and gives every point a label and one tab stop", () => {
    const out = html(createElement(LabTrend, { series: labSeries(labs)[0] }));
    expect(out).toContain('role="img"');
    expect(out).toContain("eGFR, 3 results from 3 Apr 2026 to 12 Oct 2026: it fell from 58 to 38 mL/min/1.73m2.");
    expect(out).toContain('aria-label="12 Oct 2026 · 38 mL/min/1.73m2 · Low on the record"');
    expect(out.match(/tabindex="0"/g)).toHaveLength(1);
    expect(out).toContain("Range Above 60");
    expect(out).toContain("Down 9 since 14 Aug");
  });

  it("shows a single value as a row, not a chart, and keeps the table", () => {
    const out = html(createElement(LabsPanel, { labs, lastLookedAt: null }));
    expect(out.match(/role="img"/g)).toHaveLength(1);
    expect(out).toContain("Potassium");
    expect(out).toContain("Range 3.5-5.0");
    expect(out).toContain("Lab results shared from MARMAR Health");
    expect(out).toContain("1 of 2 tests flagged on the latest result: eGFR low.");
  });

  it("puts the dose figure first and says every colour in words", () => {
    const doses: SharedDoses = {
      asOf: "2026-10-30T10:00:00Z",
      lastLookedAt: null,
      week: { taken: 12, expected: 14, onTime: 12 },
      perMedicine: [{ medicineId: "m", name: "Metformin", schedule: "twice_daily", changedAt: "", week: { onTime: 12, late: 0, skipped: 2, missed: 0, expected: 14 }, last30: { onTime: 51, late: 3, skipped: 6, missed: 0, expected: 60 } }],
      patterns: [],
    };
    const out = html(createElement(DoseBars, { doses }));
    expect(out).toContain("85");
    expect(out).toContain("12 of 14 doses taken this week, all on time.");
    expect(out).toContain("This week: 12 on time · 0 late · 2 skipped · 0 missed, of 14");
    for (const word of ["On time", "Late", "Skipped", "Missed", "Not yet due or not logged"]) expect(out).toContain(word);
    expect(out).toContain("Twice a day");
  });

  it("lists each tier with its count", () => {
    const out = html(createElement(FindingsMix, { findings: [{ tier: "today" }, { tier: "good_to_know" }, { tier: "good_to_know" }] }));
    expect(out).toContain("Review today");
    expect(out).toMatch(/Good to know<\/span><span[^>]*>2</);
    expect(html(createElement(FindingsMix, { findings: [] }))).toBe("");
  });
});