- ScheduleDocument/Schedule gain optional reminderMinutes (minutes from midnight; decode-compatible, no schema bump) - New Workout form gets a Reminder section (toggle + time picker, hidden for Now; footer warns when notifications are denied) - ReminderPlanner (pure, tested) derives notification triggers: daily repeating, per-weekday repeating for fixed days, one-shot for once - ReminderScheduler resyncs pending requests from the cache on every change, multiplexed onto onCacheChanged after the watch push; asks notification permission only once a reminder actually exists
160 lines
6.6 KiB
Swift
160 lines
6.6 KiB
Swift
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) })
|
|
}
|
|
}
|