Skip to content

Commit 5892c6c

Browse files
committed
Stabilize CI tests for proactive notifications and weekend data
1 parent 7c2a9d3 commit 5892c6c

3 files changed

Lines changed: 60 additions & 13 deletions

File tree

apps/HeartCoach/Tests/ProactiveNotificationTests.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ final class ProactiveNotificationTests: XCTestCase {
1515

1616
private var localStore: LocalStore!
1717
private var service: ProactiveNotificationService!
18+
private var notificationCenter: TestProactiveNotificationCenter!
1819
private var suiteName: String!
1920
private let config = ProactiveNotificationConfig()
2021

@@ -25,9 +26,15 @@ final class ProactiveNotificationTests: XCTestCase {
2526
defaults.removePersistentDomain(forName: suiteName)
2627
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
2728
localStore = LocalStore(defaults: defaults)
29+
notificationCenter = TestProactiveNotificationCenter()
30+
let fixedNow = Calendar(identifier: .gregorian).date(
31+
from: DateComponents(year: 2026, month: 4, day: 8, hour: 9, minute: 0)
32+
) ?? Date()
2833
service = ProactiveNotificationService(
34+
center: notificationCenter,
2935
localStore: localStore,
30-
config: config
36+
config: config,
37+
now: { fixedNow }
3138
)
3239
}
3340

@@ -38,6 +45,7 @@ final class ProactiveNotificationTests: XCTestCase {
3845
}
3946
suiteName = nil
4047
service = nil
48+
notificationCenter = nil
4149
localStore = nil
4250
super.tearDown()
4351
}
@@ -315,3 +323,20 @@ final class ProactiveNotificationTests: XCTestCase {
315323
XCTAssertGreaterThan(dates.count, 0, "Smoke test: morning briefing should schedule")
316324
}
317325
}
326+
327+
private final class TestProactiveNotificationCenter: ProactiveNotificationCenter {
328+
private var pending: [UNNotificationRequest] = []
329+
330+
func pendingNotificationRequests() async -> [UNNotificationRequest] {
331+
pending
332+
}
333+
334+
func add(_ request: UNNotificationRequest) async throws {
335+
pending.removeAll { $0.identifier == request.identifier }
336+
pending.append(request)
337+
}
338+
339+
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
340+
pending.removeAll { identifiers.contains($0.identifier) }
341+
}
342+
}

apps/HeartCoach/Tests/RealWorldDataTests.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,10 +297,17 @@ final class RealWorldDataTests: XCTestCase {
297297
// MARK: Weekend warrior pattern
298298

299299
func testRealistic_weekendWarrior_noFalseAlarms() {
300-
// Build 30 days: sedentary Mon-Fri, very active Sat-Sun
300+
// Build 30 days: sedentary Mon-Fri, very active Sat-Sun.
301+
// Use a fixed anchor date so weekday/weekend alignment is deterministic
302+
// and independent of when CI executes.
303+
let calendar = Calendar(identifier: .gregorian)
304+
let referenceSunday = calendar.date(
305+
from: DateComponents(year: 2026, month: 3, day: 29)
306+
)!
307+
301308
let data: [HeartSnapshot] = (0..<30).map { day in
302-
let date = Calendar.current.date(byAdding: .day, value: -29 + day, to: Date())!
303-
let weekday = Calendar.current.component(.weekday, from: date)
309+
let date = calendar.date(byAdding: .day, value: -29 + day, to: referenceSunday)!
310+
let weekday = calendar.component(.weekday, from: date)
304311
let isWeekend = weekday == 1 || weekday == 7
305312
var rng = SeededRNG(seed: 2000 + UInt64(day))
306313

apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
import Foundation
1414
import UserNotifications
1515

16+
// MARK: - Notification Center Abstraction
17+
18+
/// Small abstraction to make scheduling logic testable without relying on
19+
/// simulator/system notification authorization state.
20+
protocol ProactiveNotificationCenter {
21+
func pendingNotificationRequests() async -> [UNNotificationRequest]
22+
func add(_ request: UNNotificationRequest) async throws
23+
func removePendingNotificationRequests(withIdentifiers identifiers: [String])
24+
}
25+
26+
extension UNUserNotificationCenter: ProactiveNotificationCenter {}
27+
1628
// MARK: - Configuration
1729

1830
/// All thresholds in one testable struct — no magic numbers (Gemini design).
@@ -96,24 +108,27 @@ final class ProactiveNotificationService: ObservableObject {
96108

97109
// MARK: - Dependencies
98110

99-
private let center: UNUserNotificationCenter
111+
private let center: any ProactiveNotificationCenter
100112
private let localStore: LocalStore
101113
private let config: ProactiveNotificationConfig
102114
private let calendar: Calendar
115+
private let now: @Sendable () -> Date
103116
private let gate = ProactiveSchedulingGate()
104117

105118
// MARK: - Initialization
106119

107120
init(
108-
center: UNUserNotificationCenter = .current(),
121+
center: any ProactiveNotificationCenter = UNUserNotificationCenter.current(),
109122
localStore: LocalStore,
110123
config: ProactiveNotificationConfig = ProactiveNotificationConfig(),
111-
calendar: Calendar = .current
124+
calendar: Calendar = .current,
125+
now: @escaping @Sendable () -> Date = Date.init
112126
) {
113127
self.center = center
114128
self.localStore = localStore
115129
self.config = config
116130
self.calendar = calendar
131+
self.now = now
117132
}
118133

119134
// MARK: - 1. Morning Readiness Briefing
@@ -129,7 +144,7 @@ final class ProactiveNotificationService: ObservableObject {
129144
guard await canSchedule(type: type, snapshotDate: snapshotDate) else { return }
130145

131146
// Only fire before noon
132-
let hour = calendar.component(.hour, from: Date())
147+
let hour = calendar.component(.hour, from: now())
133148
guard hour < 12 else { return }
134149

135150
let levelWord: String
@@ -241,7 +256,7 @@ final class ProactiveNotificationService: ObservableObject {
241256
!overtrained else { return }
242257

243258
// Weekly cap
244-
let weekAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
259+
let weekAgo = calendar.date(byAdding: .day, value: -7, to: now()) ?? now()
245260
let recentCount = localStore.proactiveNotificationDates(for: type)
246261
.filter { $0 > weekAgo }
247262
.count
@@ -272,7 +287,7 @@ final class ProactiveNotificationService: ObservableObject {
272287

273288
// Strict cooldown: max 1 per 48h
274289
if let lastSent = localStore.proactiveNotificationDates(for: type).max() {
275-
let hoursSince = Date().timeIntervalSince(lastSent) / 3600
290+
let hoursSince = now().timeIntervalSince(lastSent) / 3600
276291
guard hoursSince >= config.illnessDetectionCooldownHours else { return }
277292
}
278293

@@ -373,12 +388,12 @@ final class ProactiveNotificationService: ObservableObject {
373388
) async -> Bool {
374389
// Data freshness
375390
if let snapshotDate {
376-
let staleHours = Date().timeIntervalSince(snapshotDate) / 3600
391+
let staleHours = now().timeIntervalSince(snapshotDate) / 3600
377392
guard staleHours < config.morningBriefingStaleHours else { return false }
378393
}
379394

380395
// Daily budget (GPT-5.4 fix #6)
381-
let today = calendar.startOfDay(for: Date())
396+
let today = calendar.startOfDay(for: now())
382397
let todayCount = ProactiveNotificationType.allCases
383398
.flatMap { localStore.proactiveNotificationDates(for: $0) }
384399
.filter { $0 >= today }
@@ -415,7 +430,7 @@ final class ProactiveNotificationService: ObservableObject {
415430

416431
do {
417432
try await center.add(request)
418-
localStore.logProactiveNotification(type: type, at: Date())
433+
localStore.logProactiveNotification(type: type, at: now())
419434
AppLogger.info("[ProactiveNotification] Scheduled: \(type.rawValue)")
420435
} catch {
421436
AppLogger.engine.warning("[ProactiveNotification] Failed to schedule \(type.rawValue): \(error.localizedDescription)")

0 commit comments

Comments
 (0)