From 5892c6c6e50483d6ce03e6ca7e7d4d1503a4fdcc Mon Sep 17 00:00:00 2001 From: mission-agi Date: Wed, 8 Apr 2026 14:40:10 -0700 Subject: [PATCH] Stabilize CI tests for proactive notifications and weekend data --- .../Tests/ProactiveNotificationTests.swift | 27 ++++++++++++++- .../HeartCoach/Tests/RealWorldDataTests.swift | 13 ++++++-- .../ProactiveNotificationService.swift | 33 ++++++++++++++----- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/HeartCoach/Tests/ProactiveNotificationTests.swift b/apps/HeartCoach/Tests/ProactiveNotificationTests.swift index 26b63271..079a6722 100644 --- a/apps/HeartCoach/Tests/ProactiveNotificationTests.swift +++ b/apps/HeartCoach/Tests/ProactiveNotificationTests.swift @@ -15,6 +15,7 @@ final class ProactiveNotificationTests: XCTestCase { private var localStore: LocalStore! private var service: ProactiveNotificationService! + private var notificationCenter: TestProactiveNotificationCenter! private var suiteName: String! private let config = ProactiveNotificationConfig() @@ -25,9 +26,15 @@ final class ProactiveNotificationTests: XCTestCase { defaults.removePersistentDomain(forName: suiteName) UNUserNotificationCenter.current().removeAllPendingNotificationRequests() localStore = LocalStore(defaults: defaults) + notificationCenter = TestProactiveNotificationCenter() + let fixedNow = Calendar(identifier: .gregorian).date( + from: DateComponents(year: 2026, month: 4, day: 8, hour: 9, minute: 0) + ) ?? Date() service = ProactiveNotificationService( + center: notificationCenter, localStore: localStore, - config: config + config: config, + now: { fixedNow } ) } @@ -38,6 +45,7 @@ final class ProactiveNotificationTests: XCTestCase { } suiteName = nil service = nil + notificationCenter = nil localStore = nil super.tearDown() } @@ -315,3 +323,20 @@ final class ProactiveNotificationTests: XCTestCase { XCTAssertGreaterThan(dates.count, 0, "Smoke test: morning briefing should schedule") } } + +private final class TestProactiveNotificationCenter: ProactiveNotificationCenter { + private var pending: [UNNotificationRequest] = [] + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + pending + } + + func add(_ request: UNNotificationRequest) async throws { + pending.removeAll { $0.identifier == request.identifier } + pending.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + pending.removeAll { identifiers.contains($0.identifier) } + } +} diff --git a/apps/HeartCoach/Tests/RealWorldDataTests.swift b/apps/HeartCoach/Tests/RealWorldDataTests.swift index 4a882e1d..e0178f02 100644 --- a/apps/HeartCoach/Tests/RealWorldDataTests.swift +++ b/apps/HeartCoach/Tests/RealWorldDataTests.swift @@ -297,10 +297,17 @@ final class RealWorldDataTests: XCTestCase { // MARK: Weekend warrior pattern func testRealistic_weekendWarrior_noFalseAlarms() { - // Build 30 days: sedentary Mon-Fri, very active Sat-Sun + // Build 30 days: sedentary Mon-Fri, very active Sat-Sun. + // Use a fixed anchor date so weekday/weekend alignment is deterministic + // and independent of when CI executes. + let calendar = Calendar(identifier: .gregorian) + let referenceSunday = calendar.date( + from: DateComponents(year: 2026, month: 3, day: 29) + )! + let data: [HeartSnapshot] = (0..<30).map { day in - let date = Calendar.current.date(byAdding: .day, value: -29 + day, to: Date())! - let weekday = Calendar.current.component(.weekday, from: date) + let date = calendar.date(byAdding: .day, value: -29 + day, to: referenceSunday)! + let weekday = calendar.component(.weekday, from: date) let isWeekend = weekday == 1 || weekday == 7 var rng = SeededRNG(seed: 2000 + UInt64(day)) diff --git a/apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift b/apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift index be736520..ec33a63d 100644 --- a/apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift +++ b/apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift @@ -13,6 +13,18 @@ import Foundation import UserNotifications +// MARK: - Notification Center Abstraction + +/// Small abstraction to make scheduling logic testable without relying on +/// simulator/system notification authorization state. +protocol ProactiveNotificationCenter { + func pendingNotificationRequests() async -> [UNNotificationRequest] + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: ProactiveNotificationCenter {} + // MARK: - Configuration /// All thresholds in one testable struct — no magic numbers (Gemini design). @@ -96,24 +108,27 @@ final class ProactiveNotificationService: ObservableObject { // MARK: - Dependencies - private let center: UNUserNotificationCenter + private let center: any ProactiveNotificationCenter private let localStore: LocalStore private let config: ProactiveNotificationConfig private let calendar: Calendar + private let now: @Sendable () -> Date private let gate = ProactiveSchedulingGate() // MARK: - Initialization init( - center: UNUserNotificationCenter = .current(), + center: any ProactiveNotificationCenter = UNUserNotificationCenter.current(), localStore: LocalStore, config: ProactiveNotificationConfig = ProactiveNotificationConfig(), - calendar: Calendar = .current + calendar: Calendar = .current, + now: @escaping @Sendable () -> Date = Date.init ) { self.center = center self.localStore = localStore self.config = config self.calendar = calendar + self.now = now } // MARK: - 1. Morning Readiness Briefing @@ -129,7 +144,7 @@ final class ProactiveNotificationService: ObservableObject { guard await canSchedule(type: type, snapshotDate: snapshotDate) else { return } // Only fire before noon - let hour = calendar.component(.hour, from: Date()) + let hour = calendar.component(.hour, from: now()) guard hour < 12 else { return } let levelWord: String @@ -241,7 +256,7 @@ final class ProactiveNotificationService: ObservableObject { !overtrained else { return } // Weekly cap - let weekAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() + let weekAgo = calendar.date(byAdding: .day, value: -7, to: now()) ?? now() let recentCount = localStore.proactiveNotificationDates(for: type) .filter { $0 > weekAgo } .count @@ -272,7 +287,7 @@ final class ProactiveNotificationService: ObservableObject { // Strict cooldown: max 1 per 48h if let lastSent = localStore.proactiveNotificationDates(for: type).max() { - let hoursSince = Date().timeIntervalSince(lastSent) / 3600 + let hoursSince = now().timeIntervalSince(lastSent) / 3600 guard hoursSince >= config.illnessDetectionCooldownHours else { return } } @@ -373,12 +388,12 @@ final class ProactiveNotificationService: ObservableObject { ) async -> Bool { // Data freshness if let snapshotDate { - let staleHours = Date().timeIntervalSince(snapshotDate) / 3600 + let staleHours = now().timeIntervalSince(snapshotDate) / 3600 guard staleHours < config.morningBriefingStaleHours else { return false } } // Daily budget (GPT-5.4 fix #6) - let today = calendar.startOfDay(for: Date()) + let today = calendar.startOfDay(for: now()) let todayCount = ProactiveNotificationType.allCases .flatMap { localStore.proactiveNotificationDates(for: $0) } .filter { $0 >= today } @@ -415,7 +430,7 @@ final class ProactiveNotificationService: ObservableObject { do { try await center.add(request) - localStore.logProactiveNotification(type: type, at: Date()) + localStore.logProactiveNotification(type: type, at: now()) AppLogger.info("[ProactiveNotification] Scheduled: \(type.rawValue)") } catch { AppLogger.engine.warning("[ProactiveNotification] Failed to schedule \(type.rawValue): \(error.localizedDescription)")