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);
});
});