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 => ({ 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); }); });