import { describe, expect, it } from "vitest"; import type { CheckIn, CheckInEvent, CheckInNote, PatientDetail, WindowRules } from "../src/api/types"; import { checkInEligibility } from "../src/logic/eligibility"; import { buildScript, closingAvailable, moveQuestion, precheckQuestion } from "../src/logic/questions"; import { confirmationValid, notReachedAlert, urgentGate } from "../src/logic/urgent"; import { allowedWindows, canStartNow, clampExpiry, clinicCloseToday, limitState, slots, windowLabel, zoneOffset } from "../src/logic/windows"; import { applyEvent, applyEvents, isSettled, unacknowledged } from "../src/logic/checkin-events"; const mariam: PatientDetail = { id: "p-mariam", kind: "connected", name: "Mariam Adeyemi", firstName: "Mariam", age: 67, sharing: { status: "connected", sharedBy: ["Mariam", "Funke"], scopes: ["medicines", "doses", "check_ins", "records"], allowsCheckInCalls: true, withheldSome: false, changedAt: "2026-10-30T09:00:00Z", }, medicines: { count: 3 }, doses: { taken: 12, expected: 14, onTime: 12 }, lastCheckIn: null, attention: { urgent: false, unreadNotes: 0, newSinceLastCheck: false }, lastActivityAt: "2026-10-30T09:00:00Z", dateOfBirth: "1959-03-04", birthYear: 1959, sex: "female", phone: "+234 803 000 0000", pronoun: "she", preferredTitle: "Mrs Adeyemi", language: "en", hasOwnAccount: true, identityOnFile: "date_of_birth", timeZone: "Africa/Lagos", quietHours: { start: "22:00", end: "07:00" }, blockedTimes: [], checkInLimits: { usedToday: 0, usedThisWeek: 0, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null }, activeCheckInId: null, lastCheckId: null, newInformation: null, }; describe("check-in eligibility (YCI-1)", () => { it("allows an adult connected patient who allowed calls", () => { expect(checkInEligibility(mariam)).toEqual({ allowed: true, notes: [] }); }); it("offers the app invite for a clinic-added patient", () => { const result = checkInEligibility({ ...mariam, kind: "clinic_added", sharing: null }); expect(result).toMatchObject({ allowed: false, code: "clinic_added", action: "invite_app" }); expect(!result.allowed && result.message).toBe("Invite Mariam to MARMAR Health to use check-ins."); }); it("says what is missing, in order", () => { const cases: [Partial, string][] = [ [{ sharing: { ...mariam.sharing!, status: "stopped" } }, "sharing_stopped"], [{ sharing: { ...mariam.sharing!, status: "paused" } }, "sharing_paused"], [{ hasOwnAccount: false }, "no_own_account"], [{ sharing: { ...mariam.sharing!, allowsCheckInCalls: false } }, "calls_not_allowed"], [{ identityOnFile: "none", dateOfBirth: null, birthYear: null }, "no_date_of_birth"], [{ age: 16 }, "under_18"], [{ activeCheckInId: "c1" }, "active_check_in"], [{ checkInLimits: { ...mariam.checkInLimits, usedToday: 1 } }, "limit_reached"], ]; for (const [change, code] of cases) expect(checkInEligibility({ ...mariam, ...change })).toMatchObject({ allowed: false, code }); }); it("uses they for a patient whose pronouns are not stated", () => { const result = checkInEligibility({ ...mariam, pronoun: null, sharing: { ...mariam.sharing!, status: "paused" } }); expect(!result.allowed && result.message).toContain("when they resume"); }); it("allows a birth year alone, and says the phone's unlock is the second check", () => { const yearOnly: PatientDetail = { ...mariam, identityOnFile: "birth_year", dateOfBirth: null }; const result = checkInEligibility(yearOnly); expect(result.allowed).toBe(true); expect(result.allowed && result.notes[0]).toContain("her unlocked phone"); }); }); describe("urgent first (YCI-1a)", () => { const now = new Date("2026-10-30T10:00:00Z"); const base = { patientId: "p-mariam", patientName: "Mariam Adeyemi", now, clinicOffset: 60 }; it("needs nothing without an Urgent review finding", () => { expect(urgentGate({ ...base, findings: [{ id: "f1", tier: "today" }], confirmation: null })).toEqual({ mode: "none", canAskYasmar: true }); }); it("leads with the call and holds Ask Yasmar until the clinician confirms", () => { const gate = urgentGate({ ...base, findings: [{ id: "f1", tier: "get_help_now" }], confirmation: null }); expect(gate).toMatchObject({ mode: "call_first", canAskYasmar: false, callLine: "Call Mariam", confirmLine: "I will speak to Mariam today" }); const confirmed = urgentGate({ ...base, findings: [{ id: "f1", tier: "get_help_now" }], confirmation: { patientId: "p-mariam", confirmedAt: "2026-10-30T09:30:00Z" }, }); expect(confirmed.canAskYasmar).toBe(true); }); it("forgets yesterday's confirmation and another patient's", () => { expect(confirmationValid({ patientId: "p-mariam", confirmedAt: "2026-10-29T20:00:00Z" }, "p-mariam", now, 60)).toBe(false); expect(confirmationValid({ patientId: "p-other", confirmedAt: "2026-10-30T09:00:00Z" }, "p-mariam", now, 60)).toBe(false); // 23:30 UTC on the 29th is 00:30 on the 30th in Lagos. expect(confirmationValid({ patientId: "p-mariam", confirmedAt: "2026-10-29T23:30:00Z" }, "p-mariam", now, 60)).toBe(true); }); it("alerts when a pressing check-in has not finished by the end of clinic hours", () => { const close = new Date("2026-10-30T16:00:00Z"); const input = { status: "ringing" as const, linkedTiers: ["today"], patientName: "Mariam Adeyemi", clinicCloseAt: close }; expect(notReachedAlert({ ...input, now: new Date("2026-10-30T15:00:00Z") })).toBeNull(); expect(notReachedAlert({ ...input, now: close })).toBe("Mariam not reached. Call today."); expect(notReachedAlert({ ...input, status: "completed", now: close })).toBeNull(); expect(notReachedAlert({ ...input, linkedTiers: ["this_week"], now: close })).toBeNull(); expect(notReachedAlert({ ...input, status: "not_reached", now: new Date("2026-10-30T11:00:00Z") })).not.toBeNull(); }); }); describe("own questions (YCI-4)", () => { const ok = (text: string) => precheckQuestion(text, "Mariam"); it("accepts a plain question", () => { expect(ok("Do you take the Metformin with food?")).toEqual({ ok: true, text: "Do you take the Metformin with food?" }); expect(ok("Have you started the Amoxicillin?").ok).toBe(true); }); it("rejects instructions with the PRD's reason", () => { expect(ok("Stop the amoxicillin today")).toEqual({ ok: false, rule: "instruction", reason: "Yasmar can ask questions but cannot give instructions. Call Mariam to tell them this.", }); expect(ok("Can you stop the Amoxicillin until I call?").ok).toBe(false); expect(ok("You should drink more water, is that okay?").ok).toBe(false); }); it("rejects results, diagnosis and reassurance", () => { expect(ok("Did you know your kidney result was low?")).toMatchObject({ rule: "result" }); expect(ok("Did they tell you that you have kidney disease?")).toMatchObject({ rule: "diagnosis" }); expect(ok("The rash is nothing to worry about, okay?")).toMatchObject({ rule: "reassurance" }); }); it("needs one question, under 200 characters", () => { expect(ok("Tell me about your sleep")).toMatchObject({ ok: false }); expect(ok("Where do you buy it")).toMatchObject({ rule: "not_question" }); expect(ok("How often? And when?")).toMatchObject({ rule: "several" }); expect(ok(`${"a".repeat(200)}?`)).toMatchObject({ rule: "too_long" }); expect(ok(" ")).toMatchObject({ rule: "empty" }); }); }); describe("the script Yasmar will say (YCI-2, YCI-20)", () => { const template = { opening: ["Good afternoon. I'm Yasmar, MARMAR's computer assistant. I'm not a person."], identity: ["Am I speaking with Mariam Adeyemi?"], consent: "Your answers go to Doctor Okafor's clinic.", feeling: "How have you been feeling?", alwaysLast: "Anything else Doctor Okafor should know?", closings: [ { id: "sent", text: "Thank you. I've sent your answers to Doctor Okafor's clinic.", requires: "none" as const, fixed: false }, { id: "today", text: "I'll let Doctor Okafor know today.", requires: "read_today" as const, fixed: false }, { id: "worse", text: "If anything gets worse, call his clinic.", requires: "none" as const, fixed: true }, ], safetyNet: "If you have trouble breathing, call 767 or 112.", }; const q = { id: "q1", text: "Have you started the Amoxicillin?", origin: "bank" as const, bankId: "amox-started", reason: "From the finding: Amoxicillin and a recorded Penicillin allergy", findingId: "f2" }; it("keeps fixed lines first and the safety net last", () => { const script = buildScript(template, [q], ["sent"], false); expect(script.map((l) => l.part)).toEqual(["opening", "identity", "consent", "feeling", "question", "last", "closing", "closing", "safety"]); expect(script.filter((l) => !l.editable).length).toBe(7); expect(script[4].reason).toContain("From the finding"); }); it("offers 'know today' only when the clinician will read them today", () => { expect(buildScript(template, [], ["today"], false).some((l) => l.text.includes("know today"))).toBe(false); expect(buildScript(template, [], ["today"], true).some((l) => l.text.includes("know today"))).toBe(true); expect(closingAvailable(template.closings[1], false)).toBe(false); }); it("moves questions within the list", () => { const list = [q, { ...q, id: "q2" }, { ...q, id: "q3" }]; expect(moveQuestion(list, "q2", -1).map((x) => x.id)).toEqual(["q2", "q1", "q3"]); expect(moveQuestion(list, "q1", -1)).toBe(list); }); }); describe("when Yasmar may ring (YCI-5) and limits (YCI-6)", () => { const weekdays = (open: string, close: string) => [1, 2, 3, 4, 5, 6].map((day) => ({ day, open, close })); const rules: Omit = { clinicHours: weekdays("08:00", "18:00"), alertWatchHours: weekdays("08:00", "18:00"), dayStart: "08:00", dayEnd: "20:00", quietHours: { start: "22:00", end: "07:00" }, blockedTimes: [], }; const lagos = { patient: 60, clinic: 60 }; it("resolves Lagos as UTC+1", () => { expect(zoneOffset("Africa/Lagos", new Date("2026-10-30T12:00:00Z"))).toBe(60); }); it("starts now inside clinic and watch hours", () => { const now = new Date("2026-10-30T10:00:00Z"); // Friday 11:00 Lagos const windows = allowedWindows(rules, now, lagos); expect(canStartNow(windows, now)).toBe(true); expect(windows[0].end.toISOString()).toBe("2026-10-30T17:00:00.000Z"); expect(windowLabel(windows[0], 60, now)).toBe("today 11:00 to 18:00"); }); it("waits for 08:00 when asked at 21:00 patient time", () => { const now = new Date("2026-10-30T20:00:00Z"); // Friday 21:00 Lagos const windows = allowedWindows(rules, now, lagos); expect(canStartNow(windows, now)).toBe(false); expect(windows[0].start.toISOString()).toBe("2026-10-31T07:00:00.000Z"); // Saturday 08:00 Lagos expect(windowLabel(windows[0], 60, now)).toBe("tomorrow 08:00 to 18:00"); }); it("never rings in quiet hours, blocked times or outside 08:00 to 20:00", () => { const late = { ...rules, clinicHours: weekdays("06:00", "23:00"), alertWatchHours: weekdays("06:00", "23:00") }; const windows = allowedWindows( { ...late, quietHours: { start: "19:00", end: "09:00" }, blockedTimes: [{ day: null, start: "13:00", end: "14:00", label: "Prayer" }] }, new Date("2026-10-30T05:00:00Z"), lagos, ); const friday = windows.filter((w) => w.start.toISOString().startsWith("2026-10-30")); expect(friday.map((w) => [w.start.toISOString(), w.end.toISOString()])).toEqual([ ["2026-10-30T08:00:00.000Z", "2026-10-30T12:00:00.000Z"], ["2026-10-30T13:00:00.000Z", "2026-10-30T18:00:00.000Z"], ]); }); it("uses the patient's clock when the clinic is in another zone", () => { // Clinic in Lagos (UTC+1), patient in Kigali (UTC+2): patient 08:00 is clinic 07:00. const now = new Date("2026-10-30T05:30:00Z"); const windows = allowedWindows(rules, now, { patient: 120, clinic: 60 }); expect(windows[0].start.toISOString()).toBe("2026-10-30T07:00:00.000Z"); expect(windows[0].end.toISOString()).toBe("2026-10-30T17:00:00.000Z"); }); it("splits windows into slots and clamps the expiry", () => { const now = new Date("2026-10-30T10:00:00Z"); expect(slots(allowedWindows(rules, now, lagos), 120)[0].end.toISOString()).toBe("2026-10-30T12:00:00.000Z"); expect(clampExpiry(24)).toBe(24); expect(clampExpiry(100)).toBe(72); expect(clampExpiry(0)).toBe(24); }); it("finds the end of today's clinic hours", () => { expect(clinicCloseToday(rules.clinicHours, new Date("2026-10-30T10:00:00Z"), 60)?.toISOString()).toBe("2026-10-30T17:00:00.000Z"); expect(clinicCloseToday(rules.clinicHours, new Date("2026-11-01T10:00:00Z"), 60)).toBeNull(); // Sunday }); it("blocks at one a day or three a week and says when", () => { const now = new Date("2026-10-30T10:00:00Z"); const limits = { usedToday: 0, usedThisWeek: 2, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null }; expect(limitState(limits, "Mariam", now)).toEqual({ blocked: false, message: "2 of 3 check-ins used this week, across all of Mariam's clinicians." }); expect(limitState({ ...limits, usedToday: 1, nextPossibleAt: "2026-10-31T07:00:00Z" }, "Mariam", now).message).toContain("The next one is possible tomorrow at"); expect(limitState({ ...limits, usedThisWeek: 3 }, "Mariam", now).blocked).toBe(true); }); }); describe("the check-in event stream", () => { const start: CheckIn = { id: "c1", patientId: "p-mariam", patientName: "Mariam Adeyemi", requestedBy: "Dr Emeka Okafor", requestedAt: "2026-10-30T10:00:00Z", plan: { questions: [], closingIds: [], window: { kind: "now" }, expiresAt: "2026-10-31T10:00:00Z", linkedFindingIds: [], linkedTiers: [], readToday: true }, status: "sent", statusAt: "2026-10-30T10:00:00Z", history: [{ status: "sent", at: "2026-10-30T10:00:00Z", detail: null }], later: null, window: null, urgent: false, urgentAcknowledgedAt: null, notes: [], summary: null, transcript: [], alerts: [], lastEventId: 1, }; const note = (id: string, at: string, urgent = false): CheckInNote => ({ id, kind: urgent ? "red_flag" : "symptom", text: id, quote: "", questionId: null, transcriptLineIds: [], at, urgent, acknowledgedAt: null, acknowledgedBy: null, disputed: false, addedToCheckAt: null, validation: "matched", endedEarly: false, }); const events: CheckInEvent[] = [ { id: 2, type: "status", at: "2026-10-30T10:00:02Z", status: "ringing", detail: null, later: null }, { id: 3, type: "status", at: "2026-10-30T10:00:08Z", status: "in_progress", detail: null, later: null }, { id: 5, type: "note", at: "2026-10-30T10:00:20Z", note: note("n2", "2026-10-30T10:00:20Z") }, { id: 4, type: "note", at: "2026-10-30T10:00:15Z", note: note("n1", "2026-10-30T10:00:15Z") }, { id: 6, type: "transcript", at: "2026-10-30T10:00:21Z", line: { id: "t1", speaker: "patient", text: "No vomiting.", offsetSeconds: 13, at: "2026-10-30T10:00:21Z" } }, ]; it("folds events in id order and skips replays", () => { const state = applyEvents(start, events); expect(state.status).toBe("in_progress"); expect(state.notes.map((n) => n.id)).toEqual(["n1", "n2"]); expect(state.lastEventId).toBe(6); expect(state.history.map((h) => h.status)).toEqual(["sent", "ringing", "in_progress"]); // A reconnect replays from Last-Event-ID 3; nothing is doubled. const again = applyEvents(state, events.slice(2)); expect(again).toBe(state); }); it("marks the call urgent on a red flag and tracks acknowledgement", () => { const state = applyEvents(start, [...events, { id: 7, type: "note", at: "2026-10-30T10:00:30Z", note: note("n3", "2026-10-30T10:00:30Z", true) }]); expect(state.urgent).toBe(true); expect(unacknowledged(state).map((n) => n.id)).toEqual(["n3"]); const acked = applyEvent(state, { id: 8, type: "note_updated", at: "2026-10-30T10:01:00Z", note: { ...note("n3", "2026-10-30T10:00:30Z", true), acknowledgedAt: "2026-10-30T10:01:00Z" } }); expect(unacknowledged(acked)).toEqual([]); expect(acked.notes).toHaveLength(3); }); it("settles only when a completed call has its summary", () => { const done = applyEvent(start, { id: 9, type: "status", at: "2026-10-30T10:01:00Z", status: "completed", detail: null, later: null }); expect(isSettled(done)).toBe(false); const summarised = applyEvent(done, { id: 10, type: "summary", at: "2026-10-30T10:01:05Z", summary: { sentences: [], createdAt: "2026-10-30T10:01:05Z" } }); expect(isSettled(summarised)).toBe(true); expect(isSettled({ status: "declined", summary: null })).toBe(true); }); it("keeps the chosen later time", () => { const later = applyEvent(start, { id: 2, type: "status", at: "2026-10-30T10:01:00Z", status: "later", detail: null, later: { start: "2026-10-30T15:00:00Z", end: "2026-10-30T17:00:00Z" } }); expect(later.later?.start).toBe("2026-10-30T15:00:00Z"); }); });