Files
workouts/WorkoutsTests/ReminderPlannerTests.swift
rzen 5ec5de9295 Add reminder notifications to schedules
- 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
2026-07-11 12:32:08 -04:00

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