marmar-context / web / context / test / clinician-patients.test.ts
clinician-patients.test.ts
Raw
import { describe, expect, it } from "vitest";
import type { InvitePreview, PatientSummary } from "../src/api/types";
import { emptyAccessForm, prefillFromInvite, sessionEnded, shouldLock, toAccessRequest, validateAccess } from "../src/logic/access";
import { asOfLabel, dayLabel, factDetail, isNewSince, listNames, pronouns, scheduleLabel, tierOf, whenLabel } from "../src/logic/format";
import { searchPatients, sharedSections, sharesLine, sortPatients, tileValues, visibleTabs, withLine } from "../src/logic/patients";

const patient = (name: string, change: Partial<PatientSummary> = {}): PatientSummary => ({
  id: name,
  kind: "connected",
  name,
  age: 60,
  pronoun: null,
  sharing: {
    status: "connected",
    sharedBy: [name.split(" ")[0]],
    scopes: ["medicines", "doses", "check_ins", "records"],
    allowsCheckInCalls: true,
    withheldSome: false,
    changedAt: "2026-10-30T09:00:00Z",
  },
  medicines: { count: 2 },
  doses: { taken: 12, expected: 14, onTime: 12 },
  lastCheckIn: null,
  attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: false },
  lastActivityAt: "2026-10-29T09:00:00Z",
  ...change,
});

describe("patients sorted by what needs attention (PAT-2)", () => {
  it("puts urgent, then unread notes, then new information, then the most recent", () => {
    const list = [
      patient("Old Quiet", { lastActivityAt: "2026-10-20T09:00:00Z" }),
      patient("Recent Quiet", { lastActivityAt: "2026-10-30T09:00:00Z" }),
      patient("New Info", { attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: true } }),
      patient("Unread Notes", { attention: { urgent: false, unreadNotes: 2, newSinceLastCheck: true } }),
      patient("Urgent Flag", { attention: { urgent: true, unreadNotes: 0, newSinceLastCheck: false }, lastActivityAt: "2026-10-01T09:00:00Z" }),
    ];
    expect(sortPatients(list).map((p) => p.name)).toEqual(["Urgent Flag", "Unread Notes", "New Info", "Recent Quiet", "Old Quiet"]);
  });
  it("searches by the start of any name, ignoring case and accents", () => {
    const list = [patient("Mariam Adeyemi"), patient("Adaeze Nwosu"), patient("Chídí Okeke")];
    expect(searchPatients(list, "ad").map((p) => p.name)).toEqual(["Mariam Adeyemi", "Adaeze Nwosu"]);
    expect(searchPatients(list, "adeyemi mar").map((p) => p.name)).toEqual(["Mariam Adeyemi"]);
    expect(searchPatients(list, "chidi").map((p) => p.name)).toEqual(["Chídí Okeke"]);
    expect(searchPatients(list, "  ")).toHaveLength(3);
  });
});

describe("what sharing shows (INV-3, SHR)", () => {
  it("shows every tab when everything is shared; Activity lives in the rail, not a tab", () => {
    expect(visibleTabs(patient("Mariam Adeyemi"))).toEqual(["overview", "shared", "check", "check_ins", "records"]);
  });
  it("hides check-ins when the patient shares only medicines and doses, without calls", () => {
    const p = patient("Chidi Okeke", {
      sharing: { ...patient("x").sharing!, scopes: ["medicines", "doses"], allowsCheckInCalls: false },
    });
    expect(visibleTabs(p)).not.toContain("check_ins");
    expect(sharedSections(p.sharing)).toEqual(["medicines", "doses"]);
    expect(sharesLine(p.sharing!.scopes)).toBe("shares medicines and doses taken");
  });
  it("hides the app view once sharing stops", () => {
    const p = patient("Halima Yusuf", { sharing: { ...patient("x").sharing!, status: "stopped" } });
    expect(visibleTabs(p)).toEqual(["overview", "check", "records"]);
    expect(sharedSections(p.sharing)).toEqual([]);
    expect(tileValues(p)).toBeNull();
  });
  it("keeps the check-in tab for clinic-added patients, to offer the app invite", () => {
    expect(visibleTabs(patient("Tunde Bakare", { kind: "clinic_added", sharing: null }))).toEqual(["overview", "check", "check_ins", "records"]);
  });
  it("fills the tile with numbers from shared areas only (SHR-1)", () => {
    const now = new Date("2026-10-30T12:00:00Z");
    const mariam = patient("Mariam Adeyemi", { lastCheckIn: { at: "2026-10-30T09:40:00Z", kind: "yasmar_call", status: "completed" } });
    expect(tileValues(mariam, now)).toEqual({
      medicines: { value: "2", note: "From their app" },
      doses: { value: "12 of 14", note: "Taken on time" },
      lastCheckIn: { value: "Today", note: "Yasmar call · Completed" },
    });
    const late = patient("A B", { doses: { taken: 5, expected: 7, onTime: 3 }, lastCheckIn: { at: "2026-10-27T09:00:00Z", kind: "yasmar_call", status: "missed" } });
    expect(tileValues(late, now)).toMatchObject({ doses: { value: "5 of 7", note: "3 on time" }, lastCheckIn: { note: "Yasmar call · Missed" } });
    expect(tileValues(patient("A B", { doses: { taken: 0, expected: 0, onTime: 0 } }), now)!.doses).toEqual({ value: "None due", note: "No doses were due this week" });
    expect(tileValues(patient("A B", { lastCheckIn: { at: "2026-10-30T08:00:00Z", kind: "daily", status: null } }), now)!.lastCheckIn.note).toBe("Daily check-in");
    expect(tileValues(patient("A B"), now)!.lastCheckIn).toEqual({ value: "None yet", note: "No check-in yet" });
  });
  it('says "Not shared" for each area the patient keeps back, never a guess (SHR-1, INV-3)', () => {
    const onlyMedicines = patient("A B", { sharing: { ...patient("x").sharing!, scopes: ["medicines"], allowsCheckInCalls: false }, doses: null });
    expect(tileValues(onlyMedicines)).toEqual({
      medicines: { value: "2", note: "From their app" },
      doses: { value: "Not shared", note: "Not part of this share" },
      lastCheckIn: { value: "Not shared", note: "Not part of this share" },
    });
    // Calls allowed without daily check-ins: the clinician's own Yasmar calls still show.
    const callsOnly = patient("A B", { sharing: { ...patient("x").sharing!, scopes: ["doses"], allowsCheckInCalls: true } });
    expect(tileValues(callsOnly)).toMatchObject({ medicines: { value: "Not shared" }, lastCheckIn: { value: "None yet" } });
    const paused = patient("A B", { sharing: { ...patient("x").sharing!, status: "paused" } });
    expect(tileValues(paused)!.medicines).toEqual({ value: "Paused", note: "Hidden until sharing resumes" });
  });
  it("names every scope in the invitation line (INV-11)", () => {
    expect(sharesLine(["records", "medicines", "check_ins", "doses"])).toBe("shares medicines, doses taken, check-ins and records");
    expect(listNames(["Mariam", "Funke"])).toBe("Mariam and Funke");
    expect(withLine({ name: "Funke", relation: "daughter" }, "she")).toBe("with her daughter Funke");
    expect(withLine({ name: "Funke", relation: "daughter" }, null)).toBe("with their daughter Funke");
  });
});

describe("display helpers", () => {
  const now = new Date("2026-10-30T12:00:00");
  it("marks only changes after the last look as new (SHR-6)", () => {
    expect(isNewSince("2026-10-30T10:00:00Z", "2026-10-30T09:00:00Z")).toBe(true);
    expect(isNewSince("2026-10-30T08:00:00Z", "2026-10-30T09:00:00Z")).toBe(false);
    expect(isNewSince("2026-10-30T10:00:00Z", null)).toBe(false);
  });
  it("labels days and times", () => {
    expect(dayLabel("2026-10-30T09:00:00", now)).toBe("Today");
    expect(dayLabel("2026-10-29T09:00:00", now)).toBe("Yesterday");
    expect(dayLabel("2026-10-27T09:00:00", now)).toBe("Tue 27 Oct");
    expect(whenLabel("2026-10-30T11:59:30", now)).toBe("just now");
    expect(asOfLabel("2026-10-30T10:42:00", now)).toBe("as of 10:42");
  });
  it("gives tiers their time, never an instruction (CHK-6)", () => {
    expect(tierOf("get_help_now")).toMatchObject({ label: "Urgent review", when: "Before the next dose" });
    expect(tierOf("today").when).toBe("By the end of clinic today");
    expect(tierOf("unknown").label).toBe("Good to know");
  });
  it("shows schedules in words, never a code (SHR-2)", () => {
    expect(scheduleLabel("twice_daily")).toBe("Twice a day");
    expect(scheduleLabel("once_daily")).toBe("Once a day");
    expect(scheduleLabel("as_needed")).toBe("When needed");
    expect(scheduleLabel("every_6_hours")).toBe("Every 6 hours");
    expect(scheduleLabel("3/day")).toBe("Three times a day");
    expect(scheduleLabel("something_new")).toBe("Something new");
    expect(scheduleLabel("Twice a day")).toBe("Twice a day");
    expect(scheduleLabel(null)).toBeNull();
    expect(factDetail("500 mg · 1 tablet · 2/day")).toBe("500 mg · 1 tablet · Twice a day");
    expect(factDetail("Strength unknown · Dose unknown · Frequency unknown")).toBe("Strength unknown · Dose unknown · Frequency unknown");
  });
  it("uses they/them unless pronouns are stated", () => {
    expect(pronouns(null).object).toBe("them");
    expect(pronouns("she").possessive).toBe("her");
  });
});

describe("request access (ACC-3, ACC-4)", () => {
  const complete = {
    ...emptyAccessForm(),
    fullName: "Emeka Okafor",
    profession: "doctor" as const,
    licenceNumber: "MDCN/R/123456",
    clinicName: "Surulere Family Clinic",
    clinicPhone: "+234 1 234 5678",
    workEmail: "Emeka@Clinic.example",
    mobile: "+234 803 123 4567",
    heardFrom: "film" as const,
    acceptedTerms: true,
  };
  it("accepts a complete request and maps the licensing body", () => {
    expect(validateAccess(complete)).toEqual({});
    expect(toAccessRequest(complete, null)).toMatchObject({ licensingBody: "MDCN", workEmail: "emeka@clinic.example", heardFromOther: null });
    expect(toAccessRequest({ ...complete, profession: "pharmacist", licenceNumber: "pcn-12345" }, "tok")).toMatchObject({ licensingBody: "PCN", licenceNumber: "PCN-12345", inviteToken: "tok" });
  });
  it("says what to fix", () => {
    const errors = validateAccess(emptyAccessForm());
    expect(Object.keys(errors).sort()).toEqual(
      ["acceptedTerms", "clinicName", "clinicPhone", "fullName", "heardFrom", "licenceNumber", "mobile", "profession", "workEmail"].sort(),
    );
    expect(validateAccess({ ...complete, licenceNumber: "12-AB" }).licenceNumber).toContain("MDCN/R/123456");
    expect(validateAccess({ ...complete, heardFrom: "other" }).heardFromOther).toBeDefined();
    expect(validateAccess({ ...complete, mobile: "0803" }).mobile).toBeDefined();
  });
  it("pre-fills from a patient's invite", () => {
    const invite: InvitePreview = {
      status: "pending",
      patientName: "Mariam Adeyemi",
      patientPronoun: "she",
      withPerson: { name: "Funke", relation: "daughter" },
      doctorName: "Dr Emeka Okafor",
      clinicName: "Surulere Family Clinic",
      doctorEmail: "emeka.okafor@example.test",
      expiresAt: "2026-11-13T00:00:00Z",
    };
    expect(prefillFromInvite(emptyAccessForm(), invite)).toMatchObject({
      fullName: "Emeka Okafor",
      profession: "doctor",
      clinicName: "Surulere Family Clinic",
      workEmail: "emeka.okafor@example.test",
      heardFrom: "patient_invite",
    });
  });
  it("locks after the idle time and ends after 12 hours (ACC-7)", () => {
    expect(shouldLock(0, 14 * 60_000, 15)).toBe(false);
    expect(shouldLock(0, 15 * 60_000, 15)).toBe(true);
    expect(sessionEnded("2026-10-30T12:00:00Z", Date.parse("2026-10-30T12:00:00Z"))).toBe(true);
  });
});