import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Coverage, Medication, StockThresholds } from "../../types"; import { buildSchedulePreview, calculateCoverage, getNextReminderForMed, getReminderStatusText, getStockStatus, } from "../../utils/schedule"; describe("buildSchedulePreview", () => { beforeEach(() => { vi.setSystemTime(new Date("2024-03-15T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); it("returns empty events for empty medications array", () => { const result = buildSchedulePreview([], "en", false); expect(result.events).toEqual([]); expect(result.today).toBe(0); expect(result.totalBlisters).toBe(0); }); it("returns empty for non-array input", () => { const result = buildSchedulePreview(null as unknown as Medication[], "en", false); expect(result.events).toEqual([]); }); it("builds events for medication with schedule", () => { const meds: Medication[] = [ { id: 1, name: "TestMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: ["John"], blisters: [ { usage: 1, every: 1, start: "2024-03-14T09:00:00", }, ], updatedAt: null, }, ]; const result = buildSchedulePreview(meds, "en", true); expect(result.events.length).toBeGreaterThan(0); expect(result.totalBlisters).toBe(1); }); it("filters out past events when includePast is false", () => { const meds: Medication[] = [ { id: 1, name: "TestMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-01T09:00:00", }, ], updatedAt: null, }, ]; const withPast = buildSchedulePreview(meds, "en", true); const withoutPast = buildSchedulePreview(meds, "en", false); expect(withPast.events.length).toBeGreaterThanOrEqual(withoutPast.events.length); }); it("handles invalid date in blister start", () => { const meds: Medication[] = [ { id: 1, name: "TestMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "invalid-date", }, ], updatedAt: null, }, ]; const result = buildSchedulePreview(meds, "en", true); // Should not crash, events for invalid dates are skipped expect(Array.isArray(result.events)).toBe(true); }); it("sorts events by time", () => { const meds: Medication[] = [ { id: 1, name: "Morning Med", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-15T09:00:00", }, ], updatedAt: null, }, { id: 2, name: "Evening Med", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-15T21:00:00", }, ], updatedAt: null, }, ]; const result = buildSchedulePreview(meds, "en", false); for (let i = 1; i < result.events.length; i++) { expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when); } }); }); describe("calculateCoverage", () => { beforeEach(() => { vi.setSystemTime(new Date("2024-03-15T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); it("calculates coverage for medication with schedule", () => { const meds: Medication[] = [ { id: 1, name: "TestMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-15T09:00:00", }, ], updatedAt: null, }, ]; const events = [{ medName: "TestMed", when: Date.now() }]; const result = calculateCoverage(meds, events, "en", 7, "automatic", new Set()); expect(result.all).toHaveLength(1); expect(result.all[0].name).toBe("TestMed"); expect(result.all[0].daysLeft).toBeDefined(); }); it("handles medication with no schedule", () => { const meds: Medication[] = [ { id: 1, name: "NoSchedule", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [], updatedAt: null, }, ]; const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set()); expect(result.all).toHaveLength(1); expect(result.all[0].daysLeft).toBeNull(); }); it("filters low stock medications", () => { const meds: Medication[] = [ { id: 1, name: "LowStock", packCount: 0, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 5, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-15T09:00:00", }, ], updatedAt: null, }, ]; const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set()); expect(result.low.length).toBeGreaterThanOrEqual(0); }); it("respects manual stock calculation mode", () => { const meds: Medication[] = [ { id: 1, name: "TestMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: [], blisters: [ { usage: 1, every: 1, start: "2024-03-10T09:00:00", }, ], updatedAt: null, }, ]; const takenDoses = new Set(["1-0-1710061200000"]); const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses); expect(result.all).toHaveLength(1); }); it("handles multiple takenBy people", () => { const meds: Medication[] = [ { id: 1, name: "SharedMed", packCount: 1, blistersPerPack: 1, pillsPerBlister: 30, looseTablets: 0, takenBy: ["Alice", "Bob"], blisters: [ { usage: 1, every: 1, start: "2024-03-15T09:00:00", }, ], updatedAt: null, }, ]; const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set()); expect(result.all).toHaveLength(1); // Daily rate should be doubled for 2 people }); }); describe("getStockStatus", () => { const thresholds: StockThresholds = { lowStockDays: 30, normalStockDays: 90, highStockDays: 180, }; it("returns out-of-stock when medsLeft is 0", () => { const result = getStockStatus(5, 0, thresholds); expect(result.level).toBe("out-of-stock"); expect(result.className).toBe("danger"); }); it("returns out-of-stock when daysLeft is 0", () => { const result = getStockStatus(0, 5, thresholds); expect(result.level).toBe("out-of-stock"); expect(result.className).toBe("danger"); }); it("returns high when daysLeft > highStockDays", () => { const result = getStockStatus(200, 100, thresholds); expect(result.level).toBe("high"); expect(result.className).toBe("high"); }); it("returns normal when daysLeft >= lowStockDays", () => { const result = getStockStatus(50, 100, thresholds); expect(result.level).toBe("normal"); expect(result.className).toBe("success"); }); it("returns low when daysLeft < lowStockDays", () => { const result = getStockStatus(20, 100, thresholds); expect(result.level).toBe("low"); expect(result.className).toBe("warning"); }); it("returns normal when daysLeft is null but medsLeft > 0", () => { const result = getStockStatus(null, 100, thresholds); expect(result.level).toBe("normal"); expect(result.label).toBe("status.noSchedule"); }); }); describe("getNextReminderForMed", () => { beforeEach(() => { vi.setSystemTime(new Date("2024-03-15T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); it('returns "—" when no depletion time', () => { const med: Coverage = { name: "Test", medsLeft: 100, daysLeft: null, depletionDate: null, depletionTime: null, nextDose: null, }; expect(getNextReminderForMed(med, 7, "en")).toBe("—"); }); it('returns "Due now" when reminder time is past', () => { const now = Date.now(); const med: Coverage = { name: "Test", medsLeft: 5, daysLeft: 3, depletionDate: null, depletionTime: now + 3 * 86400000, nextDose: null, }; // Reminder 7 days before = already past expect(getNextReminderForMed(med, 7, "en")).toBe("Due now"); }); it("returns formatted date for future reminder", () => { const now = Date.now(); const med: Coverage = { name: "Test", medsLeft: 100, daysLeft: 30, depletionDate: null, depletionTime: now + 30 * 86400000, nextDose: null, }; const result = getNextReminderForMed(med, 7, "en-US"); expect(result).not.toBe("—"); expect(result).not.toBe("Due now"); }); }); describe("getReminderStatusText", () => { const mockT = (key: string, options?: Record) => { if (options?.count) return `${key} (${options.count})`; if (options?.days) return `${key} (${options.days})`; return key; }; it("shows empty stock warning first", () => { const emptyMed: Coverage = { name: "Empty", medsLeft: 0, daysLeft: 0, depletionDate: null, depletionTime: null, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [emptyMed], null, null, null, mockT, "en"); expect(result.lines[0].text).toContain("dashboard.reminders.emptyStock"); expect(result.lines[0].className).toBe("danger-text"); }); it("shows all ok when everything is fine", () => { const healthyMed: Coverage = { name: "Healthy", medsLeft: 100, daysLeft: 60, depletionDate: null, depletionTime: Date.now() + 60 * 86400000, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [healthyMed], null, null, null, mockT, "en"); expect(result.lines[0].text).toContain("dashboard.reminders.allOk"); }); it("includes last sent info if available", () => { // For healthy meds with no upcoming reminders, it goes to the final fallback // which returns allStockOk and includes lastReminder info const healthyMed: Coverage = { name: "Healthy", medsLeft: 100, daysLeft: 200, depletionDate: null, depletionTime: Date.now() + 200 * 86400000, nextDose: null, }; const result = getReminderStatusText( 7, 30, [], [healthyMed], "2024-03-10T10:00:00Z", "stock", "email", mockT, "en" ); // Either allOk or allStockOk includes last reminder info const hasLastReminder = result.lines.some( (l) => l.text.includes("lastReminder") || l.text.includes("allOk") || l.text.includes("allStockOk") ); expect(hasLastReminder).toBe(true); }); it("shows low warning for medications running low", () => { const lowMed: Coverage = { name: "RunningLow", medsLeft: 20, daysLeft: 20, depletionDate: null, depletionTime: Date.now() + 20 * 86400000, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en"); expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true); }); it("handles intake reminder type with push channel", () => { const emptyMed: Coverage = { name: "Empty", medsLeft: 0, daysLeft: 0, depletionDate: null, depletionTime: null, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [emptyMed], "2024-03-10T10:00:00Z", "intake", "push", mockT, "en"); expect(result.lines[0].className).toBe("danger-text"); }); it("handles both channel type", () => { const emptyMed: Coverage = { name: "Empty", medsLeft: 0, daysLeft: 0, depletionDate: null, depletionTime: null, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [emptyMed], "2024-03-10T10:00:00Z", "stock", "both", mockT, "en"); expect(result.lines[0].className).toBe("danger-text"); }); it("shows needRefill when below critical threshold", () => { const criticalMed: Coverage = { name: "Critical", medsLeft: 5, daysLeft: 5, depletionDate: null, depletionTime: Date.now() + 5 * 86400000, nextDose: null, }; const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en"); expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true); }); it("shows low warning when below low threshold but above critical", () => { const lowMed: Coverage = { name: "Low", medsLeft: 20, daysLeft: 20, depletionDate: null, depletionTime: Date.now() + 20 * 86400000, nextDose: null, }; const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en"); expect(result.lines.some((l) => l.text.includes("lowWarning"))).toBe(true); }); it("returns noRemindersNeeded when all ok and no last sent", () => { const result = getReminderStatusText(7, 30, [], [], null, null, null, mockT, "en"); expect(result.lines.some((l) => l.text.includes("noRemindersNeeded") || l.text.includes("allStockOk"))).toBe(true); }); it("handles empty and critical meds together", () => { const emptyMed: Coverage = { name: "Empty", medsLeft: 0, daysLeft: 0, depletionDate: null, depletionTime: null, nextDose: null, }; const criticalMed: Coverage = { name: "Critical", medsLeft: 5, daysLeft: 5, depletionDate: null, depletionTime: Date.now() + 5 * 86400000, nextDose: null, }; const lowMed: Coverage = { name: "Low", medsLeft: 20, daysLeft: 20, depletionDate: null, depletionTime: Date.now() + 20 * 86400000, nextDose: null, }; const result = getReminderStatusText( 7, 30, [criticalMed], [emptyMed, criticalMed, lowMed], null, null, null, mockT, "en" ); expect(result.lines[0].text).toContain("emptyStock"); expect(result.lines.length).toBeGreaterThan(1); }); });