import Foundation import Testing @testable import Workouts /// Locks `ReminderPlanner`'s pure trigger derivation — schedule snapshots in, /// (identifier, DateComponents, repeats) plans out. `UNUserNotificationCenter` /// itself is deliberately untested; the scheduler is a thin adapter over these plans. struct ReminderPlannerTests { /// UTC Gregorian so nothing depends on the host machine's timezone. private let cal: Calendar = { var cal = Calendar(identifier: .gregorian) cal.timeZone = TimeZone(identifier: "UTC")! return cal }() /// Midnight UTC on an ISO `yyyy-MM-dd` date. private func day(_ iso: String) -> Date { let parts = iso.split(separator: "-").compactMap { Int($0) } var components = DateComponents() components.year = parts[0]; components.month = parts[1]; components.day = parts[2] return cal.date(from: components)! } private func source( id: String = "01SCHEDULE0000000000000001", routineName: String = "Push Day", recurrence: ScheduleRecurrence, weekdays: [Int]? = nil, date: Date? = nil, reminderMinutes: Int? ) -> ReminderSource { ReminderSource(id: id, routineName: routineName, recurrence: recurrence, weekdays: weekdays, date: date, reminderMinutes: reminderMinutes) } // MARK: - Daily @Test func dailyProducesOneRepeatingHourMinuteTrigger() throws { let plans = ReminderPlanner.plans( schedules: [source(recurrence: .daily, reminderMinutes: 7 * 60 + 30)], calendar: cal, now: day("2026-07-11")) let plan = try #require(plans.first) #expect(plans.count == 1) #expect(plan.identifier == "reminder-01SCHEDULE0000000000000001") #expect(plan.routineName == "Push Day") #expect(plan.repeats) #expect(plan.dateComponents.hour == 7) #expect(plan.dateComponents.minute == 30) // Only hour+minute — no weekday/day, so it fires every day. #expect(plan.dateComponents.weekday == nil) #expect(plan.dateComponents.day == nil) } // MARK: - Fixed days @Test func fixedDaysProducesOneRepeatingTriggerPerWeekdaySorted() { let plans = ReminderPlanner.plans( schedules: [source(recurrence: .fixedDays, weekdays: [5, 2], reminderMinutes: 18 * 60)], calendar: cal, now: day("2026-07-11")) #expect(plans.count == 2) #expect(plans.map(\.identifier) == [ "reminder-01SCHEDULE0000000000000001-2", "reminder-01SCHEDULE0000000000000001-5", ]) #expect(plans.allSatisfy { $0.repeats }) #expect(plans.map(\.dateComponents.weekday) == [2, 5]) #expect(plans.allSatisfy { $0.dateComponents.hour == 18 && $0.dateComponents.minute == 0 }) } @Test func fixedDaysIgnoresOutOfRangeWeekdaysAndEmptyListYieldsNothing() { let outOfRange = ReminderPlanner.plans( schedules: [source(recurrence: .fixedDays, weekdays: [0, 3, 9], reminderMinutes: 60)], calendar: cal, now: day("2026-07-11")) #expect(outOfRange.map(\.dateComponents.weekday) == [3]) let empty = ReminderPlanner.plans( schedules: [source(recurrence: .fixedDays, weekdays: [], reminderMinutes: 60)], calendar: cal, now: day("2026-07-11")) #expect(empty.isEmpty) } // MARK: - Once @Test func onceFutureProducesOneNonRepeatingFullDateTrigger() throws { // Reminder at 06:15 on 2026-07-20; "now" is well before it. let plans = ReminderPlanner.plans( schedules: [source(recurrence: .once, date: day("2026-07-20"), reminderMinutes: 6 * 60 + 15)], calendar: cal, now: day("2026-07-11")) let plan = try #require(plans.first) #expect(plans.count == 1) #expect(!plan.repeats) #expect(plan.dateComponents.year == 2026) #expect(plan.dateComponents.month == 7) #expect(plan.dateComponents.day == 20) #expect(plan.dateComponents.hour == 6) #expect(plan.dateComponents.minute == 15) } @Test func oncePastMomentProducesNothing() { let reminderDay = day("2026-07-11") // The whole day is past. let dayPast = ReminderPlanner.plans( schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)], calendar: cal, now: day("2026-07-12")) #expect(dayPast.isEmpty) // Same day, but the reminder minute (08:00) has already passed by noon. let minutePast = ReminderPlanner.plans( schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)], calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!) #expect(minutePast.isEmpty) // Same day, reminder still ahead (20:00 vs noon) — scheduled. let stillAhead = ReminderPlanner.plans( schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 20 * 60)], calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!) #expect(stillAhead.count == 1) } @Test func onceWithoutDateProducesNothing() { let plans = ReminderPlanner.plans( schedules: [source(recurrence: .once, date: nil, reminderMinutes: 8 * 60)], calendar: cal, now: day("2026-07-11")) #expect(plans.isEmpty) } // MARK: - No reminder @Test func nilReminderMinutesContributesNothingAcrossAllRecurrences() { let plans = ReminderPlanner.plans( schedules: [ source(id: "A", recurrence: .daily, reminderMinutes: nil), source(id: "B", recurrence: .fixedDays, weekdays: [2, 4], reminderMinutes: nil), source(id: "C", recurrence: .once, date: day("2026-08-01"), reminderMinutes: nil), ], calendar: cal, now: day("2026-07-11")) #expect(plans.isEmpty) } // MARK: - Mixed batch @Test func mixedSchedulesFlattenInInputOrderWithPrefixOnEveryIdentifier() { let plans = ReminderPlanner.plans( schedules: [ source(id: "A", recurrence: .daily, reminderMinutes: 6 * 60), source(id: "B", recurrence: .fixedDays, weekdays: [2, 6], reminderMinutes: 17 * 60), source(id: "C", recurrence: .daily, reminderMinutes: nil), ], calendar: cal, now: day("2026-07-11")) #expect(plans.map(\.identifier) == ["reminder-A", "reminder-B-2", "reminder-B-6"]) #expect(plans.allSatisfy { $0.identifier.hasPrefix(ReminderPlanner.identifierPrefix) }) } }