From 91157da2bb7be5fba3a8e64a5fca8d17586b84f0 Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 11:16:20 -0700 Subject: [PATCH 1/3] Refactor dashboard tab routing --- .../Shared/Services/DashboardTabRouter.swift | 53 +++++++++++ .../Tests/DashboardTabRouterTests.swift | 44 +++++++++ .../iOS/Views/DashboardView+BuddyCards.swift | 26 ------ .../iOS/Views/DashboardView+ThumpCheck.swift | 89 +------------------ 4 files changed, 98 insertions(+), 114 deletions(-) create mode 100644 apps/HeartCoach/Shared/Services/DashboardTabRouter.swift create mode 100644 apps/HeartCoach/Tests/DashboardTabRouterTests.swift diff --git a/apps/HeartCoach/Shared/Services/DashboardTabRouter.swift b/apps/HeartCoach/Shared/Services/DashboardTabRouter.swift new file mode 100644 index 0000000..c397ef3 --- /dev/null +++ b/apps/HeartCoach/Shared/Services/DashboardTabRouter.swift @@ -0,0 +1,53 @@ +// DashboardTabRouter.swift +// ThumpCore +// +// Centralizes dashboard navigation intents so layout changes do not break +// dashboard card taps or deep links. + +import Foundation + +public enum DashboardTabDestination: Equatable, Sendable { + case insights + case stress + case trends + case settings +} + +public enum DashboardTabRouter { + + public static func tabIndex( + for destination: DashboardTabDestination, + useNewTabLayout: Bool + ) -> Int { + if useNewTabLayout { + switch destination { + case .insights, .stress, .trends: + return 1 + case .settings: + return 2 + } + } + + switch destination { + case .insights: + return 1 + case .stress: + return 2 + case .trends: + return 3 + case .settings: + return 4 + } + } + + public static func destination(for category: NudgeCategory) -> DashboardTabDestination { + switch category { + case .rest, .breathe, .seekGuidance: + return .stress + case .walk, .moderate, .intensity: + return .trends + case .hydrate, .sunlight, .celebrate: + return .insights + } + } +} diff --git a/apps/HeartCoach/Tests/DashboardTabRouterTests.swift b/apps/HeartCoach/Tests/DashboardTabRouterTests.swift new file mode 100644 index 0000000..c8f0359 --- /dev/null +++ b/apps/HeartCoach/Tests/DashboardTabRouterTests.swift @@ -0,0 +1,44 @@ +// DashboardTabRouterTests.swift +// ThumpCoreTests +// +// Regression tests for dashboard-to-tab routing. Protects against +// hard-coded index drift when switching between legacy 5-tab and +// new 3-tab layouts. + +import XCTest +@testable import Thump + +final class DashboardTabRouterTests: XCTestCase { + + func testLegacyTabMapping_usesExpectedIndices() { + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: false), 1) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: false), 2) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: false), 3) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: false), 4) + } + + func testNewTabMapping_collapsesToTrendsAndYou() { + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: true), 1) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: true), 1) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: true), 1) + XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: true), 2) + } + + func testCategoryRouting_restAndBreathe_goToStressIntent() { + XCTAssertEqual(DashboardTabRouter.destination(for: .rest), .stress) + XCTAssertEqual(DashboardTabRouter.destination(for: .breathe), .stress) + XCTAssertEqual(DashboardTabRouter.destination(for: .seekGuidance), .stress) + } + + func testCategoryRouting_activityCategories_goToTrendsIntent() { + XCTAssertEqual(DashboardTabRouter.destination(for: .walk), .trends) + XCTAssertEqual(DashboardTabRouter.destination(for: .moderate), .trends) + XCTAssertEqual(DashboardTabRouter.destination(for: .intensity), .trends) + } + + func testCategoryRouting_supportiveCategories_goToInsightsIntent() { + XCTAssertEqual(DashboardTabRouter.destination(for: .hydrate), .insights) + XCTAssertEqual(DashboardTabRouter.destination(for: .sunlight), .insights) + XCTAssertEqual(DashboardTabRouter.destination(for: .celebrate), .insights) + } +} diff --git a/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift b/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift index bea797f..ebed5b2 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+BuddyCards.swift @@ -288,30 +288,4 @@ extension DashboardView { } } - /// Maps a recommendation category to the metric it improves. - func metricImpactLabel(_ category: NudgeCategory) -> String { - switch category { - case .walk: return "Improves VO2 max & recovery" - case .rest: return "Lowers resting heart rate" - case .hydrate: return "Supports HRV & recovery" - case .breathe: return "Reduces stress score" - case .moderate: return "Boosts cardio fitness" - case .celebrate: return "Keep it up!" - case .seekGuidance: return "Protect your heart health" - case .sunlight: return "Improves sleep & circadian rhythm" - } - } - - func metricImpactIcon(_ category: NudgeCategory) -> String { - switch category { - case .walk: return "arrow.up.heart.fill" - case .rest: return "heart.fill" - case .hydrate: return "waveform.path.ecg" - case .breathe: return "brain.head.profile" - case .moderate: return "lungs.fill" - case .celebrate: return "star.fill" - case .seekGuidance: return "shield.fill" - case .sunlight: return "moon.zzz.fill" - } - } } diff --git a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift index cf8c7e3..cc5f0e1 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift @@ -534,12 +534,7 @@ extension DashboardView { .accessibilityLabel("RHR trend: \(Int(trend.baselineMean)) to \(Int(trend.currentWeekMean)) bpm, \(trendLabel(trend.direction))") .onTapGesture { InteractionLog.log(.cardTap, element: "wow_trend_banner", page: "Dashboard") - NotificationCenter.default.post( - name: .thumpOpenTrendsMetric, - object: nil, - userInfo: [ThumpSharedKeys.trendsMetricKey: recoveryDrillDownMetric] - ) - withAnimation { selectedTab = 3 } + navigate(to: .trends) } } @@ -663,88 +658,6 @@ extension DashboardView { .presentationDetents([.medium, .large]) } - /// Shows week-over-week RHR change and recovery trend as a compact banner. - func weekOverWeekBanner(_ trend: WeekOverWeekTrend) -> some View { - let rhrChange = trend.currentWeekMean - trend.baselineMean - let rhrArrow = rhrChange <= -1 ? "↓" : rhrChange >= 1 ? "↑" : "→" - let rhrColor: Color = rhrChange <= -1 - ? Color(hex: 0x22C55E) - : rhrChange >= 1 ? Color(hex: 0xEF4444) : .secondary - - return VStack(spacing: 6) { - // RHR trend line - HStack(spacing: 6) { - Image(systemName: trend.direction.icon) - .font(.caption2) - .foregroundStyle(rhrColor) - Text("RHR \(Int(trend.baselineMean)) \(rhrArrow) \(Int(trend.currentWeekMean)) bpm") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.primary) - Spacer() - Text(trendLabel(trend.direction)) - .font(.system(size: 9)) - .foregroundStyle(rhrColor) - } - - // Recovery trend line (if available) - if let recovery = viewModel.assessment?.recoveryTrend, - recovery.direction != .insufficientData, - let current = recovery.currentWeekMean, - let baseline = recovery.baselineMean { - let recChange = current - baseline - let recArrow = recChange >= 1 ? "↑" : recChange <= -1 ? "↓" : "→" - let recColor: Color = recChange >= 1 - ? Color(hex: 0x22C55E) - : recChange <= -1 ? Color(hex: 0xEF4444) : .secondary - - HStack(spacing: 6) { - Image(systemName: "arrow.uturn.up") - .font(.caption2) - .foregroundStyle(recColor) - Text("Recovery \(Int(baseline)) \(recArrow) \(Int(current)) bpm drop") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.primary) - Spacer() - Text(recoveryDirectionLabel(recovery.direction)) - .font(.system(size: 9)) - .foregroundStyle(recColor) - } - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(.tertiarySystemGroupedBackground)) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel("RHR trend: \(Int(trend.baselineMean)) to \(Int(trend.currentWeekMean)) bpm, \(trendLabel(trend.direction))") - .onTapGesture { - InteractionLog.log(.cardTap, element: "wow_trend_banner", page: "Dashboard") - withAnimation { selectedTab = 3 } - } - } - - func trendLabel(_ direction: WeeklyTrendDirection) -> String { - switch direction { - case .significantImprovement: return "Improving fast" - case .improving: return "Trending down" - case .stable: return "Steady" - case .elevated: return "Creeping up" - case .significantElevation: return "Elevated" - } - } - - func recoveryDirectionLabel(_ direction: RecoveryTrendDirection) -> String { - switch direction { - case .improving: return "Getting faster" - case .stable: return "Steady" - case .declining: return "Slowing down" - case .insufficientData: return "Not enough data" - } - } - func readinessColor(for level: ReadinessLevel) -> Color { switch level { case .primed: return Color(hex: 0x22C55E) From 9aa192ef702761e782e1de2e08b062bfc687897c Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 11:16:44 -0700 Subject: [PATCH 2/3] Align stress guidance behavior --- .../Tests/ClickableDataFlowTests.swift | 12 ++- .../CrossTabMessageConsistencyTests.swift | 88 +++++++++++++++++++ .../Tests/StressViewModelTests.swift | 13 ++- .../iOS/ViewModels/StressViewModel.swift | 2 +- .../iOS/Views/AdvicePresenter.swift | 2 +- .../iOS/Views/StressSmartActionsView.swift | 4 +- 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 apps/HeartCoach/Tests/CrossTabMessageConsistencyTests.swift diff --git a/apps/HeartCoach/Tests/ClickableDataFlowTests.swift b/apps/HeartCoach/Tests/ClickableDataFlowTests.swift index 7430c4f..53d54c6 100644 --- a/apps/HeartCoach/Tests/ClickableDataFlowTests.swift +++ b/apps/HeartCoach/Tests/ClickableDataFlowTests.swift @@ -816,8 +816,8 @@ final class StressClickableDataFlowTests: XCTestCase { XCTAssertTrue(vm.walkSuggestionShown) } - /// handleSmartAction routes .restSuggestion to breathing session. - func testHandleSmartAction_restSuggestion_startsBreathing() { + /// handleSmartAction routes .restSuggestion to a reminder flow, not breathing. + func testHandleSmartAction_restSuggestion_dismissesWithoutBreathing() { let vm = StressViewModel() let nudge = DailyNudge( category: .rest, @@ -826,8 +826,14 @@ final class StressClickableDataFlowTests: XCTestCase { durationMinutes: nil, icon: "bed.double.fill" ) + vm.smartActions = [.restSuggestion(nudge), .standardNudge] + vm.smartAction = .restSuggestion(nudge) vm.handleSmartAction(.restSuggestion(nudge)) - XCTAssertTrue(vm.isBreathingSessionActive) + XCTAssertFalse(vm.isBreathingSessionActive) + XCTAssertFalse(vm.smartActions.contains(where: { + if case .restSuggestion = $0 { return true } + return false + })) } // MARK: - Day Selection in Week View diff --git a/apps/HeartCoach/Tests/CrossTabMessageConsistencyTests.swift b/apps/HeartCoach/Tests/CrossTabMessageConsistencyTests.swift new file mode 100644 index 0000000..a179fa8 --- /dev/null +++ b/apps/HeartCoach/Tests/CrossTabMessageConsistencyTests.swift @@ -0,0 +1,88 @@ +// CrossTabMessageConsistencyTests.swift +// ThumpTests +// +// Regression coverage for contradictory guidance across Dashboard and Stress +// surfaces. These tests protect against "push hard" messaging when readiness +// is still low due to poor sleep or recovery debt. + +import XCTest +@testable import Thump + +final class CrossTabMessageConsistencyTests: XCTestCase { + + func testStressGuidance_relaxedButRecoveringReadiness_avoidsPerformanceActions() { + let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering) + + XCTAssertFalse(spec.actions.contains("Workout"), "Recovering readiness should not promote hard workouts.") + XCTAssertFalse(spec.actions.contains("Focus Time"), "Recovering readiness should avoid high-cognitive push framing.") + XCTAssertTrue(spec.actions.contains("Rest"), "Recovering readiness should include recovery actions.") + XCTAssertTrue(spec.actions.contains("Take a Walk"), "Recovering readiness should include low-intensity movement.") + } + + func testStressGuidance_relaxedAndReady_keepsPerformanceActions() { + let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .ready) + + XCTAssertTrue(spec.actions.contains("Workout"), "Ready state should still allow performance-oriented actions.") + XCTAssertTrue(spec.actions.contains("Focus Time"), "Ready state should preserve focus-window coaching.") + } + + func testPoorSleepDashboardAndStressGuidance_areAligned() { + let snapshot = HeartSnapshot( + date: Date(), + restingHeartRate: 62, + hrvSDNN: 56, + recoveryHR1m: 24, + sleepHours: 4.7 + ) + let state = makeRecoveryAdviceState(stressGuidanceLevel: .relaxed) + + let dashboardText = AdvicePresenter.checkRecommendation( + for: state, + readinessScore: 42, + snapshot: snapshot + ).lowercased() + let stressSpec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering) + + let recoverySignals = [ + "skip structured training", + "easy walk", + "save harder sessions", + "keep it light", + "rest" + ] + + XCTAssertTrue( + recoverySignals.contains(where: { dashboardText.contains($0) }), + "Low-readiness + poor-sleep dashboard guidance should clearly bias recovery." + ) + XCTAssertFalse( + dashboardText.contains("push hard") || dashboardText.contains("high-intensity"), + "Low-readiness + poor-sleep dashboard guidance should avoid hard-intensity wording." + ) + XCTAssertFalse(stressSpec.actions.contains("Workout")) + XCTAssertFalse(stressSpec.actions.contains("Focus Time")) + } + + private func makeRecoveryAdviceState(stressGuidanceLevel: StressGuidanceLevel?) -> AdviceState { + AdviceState( + mode: .lightRecovery, + riskBand: .elevated, + overtrainingState: .none, + sleepDeprivationFlag: true, + medicalEscalationFlag: false, + heroCategory: .caution, + heroMessageID: "hero_rough_night", + buddyMoodCategory: .resting, + focusInsightID: "insight_rough_night", + checkBadgeID: "badge_recover", + goals: [], + recoveryDriver: .lowSleep, + stressGuidanceLevel: stressGuidanceLevel, + smartActions: [], + allowedIntensity: .light, + nudgePriorities: [.rest, .walk], + positivityAnchorID: nil, + dailyActionBudget: 3 + ) + } +} diff --git a/apps/HeartCoach/Tests/StressViewModelTests.swift b/apps/HeartCoach/Tests/StressViewModelTests.swift index 81f91fb..f5b6839 100644 --- a/apps/HeartCoach/Tests/StressViewModelTests.swift +++ b/apps/HeartCoach/Tests/StressViewModelTests.swift @@ -317,9 +317,9 @@ final class StressViewModelTests: XCTestCase { })) } - // MARK: - Handle Smart Action: restSuggestion Starts Breathing + // MARK: - Handle Smart Action: restSuggestion Dismisses Reminder Card - func testHandleSmartAction_restSuggestion_startsBreathing() { + func testHandleSmartAction_restSuggestion_dismissesCardWithoutStartingBreathing() { let nudge = DailyNudge( category: .rest, title: "Rest", @@ -328,9 +328,16 @@ final class StressViewModelTests: XCTestCase { icon: "bed.double.fill" ) let vm = StressViewModel() + vm.smartActions = [.restSuggestion(nudge), .standardNudge] + vm.smartAction = .restSuggestion(nudge) + vm.handleSmartAction(.restSuggestion(nudge)) - XCTAssertTrue(vm.isBreathingSessionActive) + XCTAssertFalse(vm.isBreathingSessionActive) + XCTAssertFalse(vm.smartActions.contains(where: { + if case .restSuggestion = $0 { return true } + return false + })) } // MARK: - Custom Breathing Duration diff --git a/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift b/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift index c55b548..e134e37 100644 --- a/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift +++ b/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift @@ -36,7 +36,7 @@ final class StressViewModel: ObservableObject { @Published var hourlyReferenceDate: Date = Date() /// The currently selected time range. - @Published var selectedRange: TimeRange = .day { + @Published var selectedRange: TimeRange = .week { didSet { Task { await loadData(force: false) } } diff --git a/apps/HeartCoach/iOS/Views/AdvicePresenter.swift b/apps/HeartCoach/iOS/Views/AdvicePresenter.swift index 7dbb8ef..11582d7 100644 --- a/apps/HeartCoach/iOS/Views/AdvicePresenter.swift +++ b/apps/HeartCoach/iOS/Views/AdvicePresenter.swift @@ -205,7 +205,7 @@ struct AdvicePresenter { detail: "Stress is calm, but recovery is still catching up. Keep effort light and stay consistent today.", icon: "leaf.fill", colorName: "relaxed", - actions: ["Easy Walk", "Focus Time"] + actions: ["Rest", "Take a Walk"] ) } return StressGuidanceSpec( diff --git a/apps/HeartCoach/iOS/Views/StressSmartActionsView.swift b/apps/HeartCoach/iOS/Views/StressSmartActionsView.swift index ae62ddb..7ebac3c 100644 --- a/apps/HeartCoach/iOS/Views/StressSmartActionsView.swift +++ b/apps/HeartCoach/iOS/Views/StressSmartActionsView.swift @@ -271,8 +271,8 @@ extension StressView { icon: "leaf.fill", color: ThumpColors.relaxed, actions: [ - QuickAction(label: "Easy Walk", icon: "figure.walk"), - QuickAction(label: "Focus Time", icon: "brain.head.profile") + QuickAction(label: "Rest", icon: "bed.double.fill"), + QuickAction(label: "Take a Walk", icon: "figure.walk") ] ) } From 0ad50f061a730409508051388840c24748106fad Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 11:17:02 -0700 Subject: [PATCH 3/3] Harden stress engine and notification tests --- .../Shared/Engine/StressEngine.swift | 14 +- .../Shared/Services/LocalStore.swift | 4 + .../Services/ProactiveNotificationStore.swift | 4 +- .../Tests/PersonaSimulationAuditTests.swift | 211 ++++++++++++++++++ .../Tests/ProactiveNotificationTests.swift | 20 +- apps/HeartCoach/Tests/StressEngineTests.swift | 20 ++ 6 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 apps/HeartCoach/Tests/PersonaSimulationAuditTests.swift diff --git a/apps/HeartCoach/Shared/Engine/StressEngine.swift b/apps/HeartCoach/Shared/Engine/StressEngine.swift index aa08a8e..ec2ee36 100644 --- a/apps/HeartCoach/Shared/Engine/StressEngine.swift +++ b/apps/HeartCoach/Shared/Engine/StressEngine.swift @@ -912,13 +912,23 @@ public struct StressEngine: Sendable { guard let snapshot = snapshots.first(where: { calendar.isDate($0.date, inSameDayAs: targetDay) - }), let dailyHRV = snapshot.hrvSDNN else { + }) else { return [] } let preceding = snapshots.filter { $0.date < targetDay } + let fallbackDailyHRV = preceding + .sorted(by: { $0.date < $1.date }) + .compactMap(\.hrvSDNN) + .last + guard let dailyHRV = snapshot.hrvSDNN + ?? fallbackDailyHRV + ?? computeBaseline(snapshots: preceding) else { + return [] + } // Use preceding days for baseline when available; fall back to today's - // own HRV so the Day heatmap works on day 1 (BUG-072). + // own HRV or the most recent usable HRV so the Day heatmap works even + // when the latest daily snapshot is partially missing (BUG-072). let baseline = computeBaseline(snapshots: preceding) ?? dailyHRV return hourlyStressEstimates( diff --git a/apps/HeartCoach/Shared/Services/LocalStore.swift b/apps/HeartCoach/Shared/Services/LocalStore.swift index 7146bd5..a2f05d4 100644 --- a/apps/HeartCoach/Shared/Services/LocalStore.swift +++ b/apps/HeartCoach/Shared/Services/LocalStore.swift @@ -61,6 +61,10 @@ public final class LocalStore: ObservableObject { private let encoder: JSONEncoder private let decoder: JSONDecoder + /// Exposes the backing defaults to same-module extensions that persist + /// additional feature state without duplicating configuration. + var storageDefaults: UserDefaults { defaults } + // MARK: - Initialization /// Creates a new `LocalStore` backed by the given `UserDefaults` suite. diff --git a/apps/HeartCoach/Shared/Services/ProactiveNotificationStore.swift b/apps/HeartCoach/Shared/Services/ProactiveNotificationStore.swift index ae107d4..b4000e2 100644 --- a/apps/HeartCoach/Shared/Services/ProactiveNotificationStore.swift +++ b/apps/HeartCoach/Shared/Services/ProactiveNotificationStore.swift @@ -53,7 +53,7 @@ extension LocalStore { // MARK: - Private Persistence private func loadProactiveHistory() -> [String: [Date]] { - guard let data = UserDefaults.standard.data(forKey: Self.proactiveHistoryKey), + guard let data = storageDefaults.data(forKey: Self.proactiveHistoryKey), let decoded = try? JSONDecoder().decode([String: [Date]].self, from: data) else { return [:] } @@ -62,6 +62,6 @@ extension LocalStore { private func saveProactiveHistory(_ history: [String: [Date]]) { guard let data = try? JSONEncoder().encode(history) else { return } - UserDefaults.standard.set(data, forKey: Self.proactiveHistoryKey) + storageDefaults.set(data, forKey: Self.proactiveHistoryKey) } } diff --git a/apps/HeartCoach/Tests/PersonaSimulationAuditTests.swift b/apps/HeartCoach/Tests/PersonaSimulationAuditTests.swift new file mode 100644 index 0000000..2ce4c0b --- /dev/null +++ b/apps/HeartCoach/Tests/PersonaSimulationAuditTests.swift @@ -0,0 +1,211 @@ +import Foundation +import XCTest +@testable import Thump + +final class PersonaSimulationAuditTests: XCTestCase { + private let trendEngine = ConfigService.makeDefaultEngine() + private let stressEngine = StressEngine() + private let readinessEngine = ReadinessEngine() + private let coachingEngine = CoachingEngine() + private let correlationEngine = CorrelationEngine() + private let zoneEngine = HeartRateZoneEngine() + private let scheduler = SmartNudgeScheduler() + + private let personas: [MockData.Persona] = [ + .athleticMale, + .normalFemale, + .couchPotatoMale, + .overweightFemale, + .seniorActive, + ] + + private let bannedTerms = [ + "lorem ipsum", + "sdnn", + "z-score", + "p-value", + "regression analysis", + "crushing it", + "killing it", + ] + + private func fixedReferenceDate() -> Date { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: "America/Los_Angeles") + components.year = 2026 + components.month = 3 + components.day = 12 + components.hour = 21 + components.minute = 0 + return components.date ?? Date() + } + + private func stressResult( + current: HeartSnapshot, + prior: [HeartSnapshot] + ) -> StressResult? { + guard let currentHRV = current.hrvSDNN else { return nil } + + let baselineHRV = stressEngine.computeBaseline(snapshots: prior) ?? currentHRV + let recentHRVs = prior.compactMap(\.hrvSDNN) + let baselineSD = stressEngine.computeBaselineSD( + hrvValues: recentHRVs, + mean: baselineHRV + ) + let baselineRHR = stressEngine.computeRHRBaseline(snapshots: prior) + + return stressEngine.computeStress( + currentHRV: currentHRV, + baselineHRV: baselineHRV, + baselineHRVSD: baselineSD, + currentRHR: current.restingHeartRate, + baselineRHR: baselineRHR, + recentHRVs: recentHRVs + ) + } + + private func assertReadable( + _ text: String, + context: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertFalse(trimmed.isEmpty, "\(context): text should not be empty", file: file, line: line) + XCTAssertGreaterThan(trimmed.count, 6, "\(context): text is too short -> \(trimmed)", file: file, line: line) + + let lower = trimmed.lowercased() + for term in bannedTerms { + XCTAssertFalse( + lower.contains(term), + "\(context): found banned term '\(term)' in '\(trimmed)'", + file: file, + line: line + ) + } + } + + private func actionTextFragments(_ action: SmartNudgeAction) -> [String] { + switch action { + case .journalPrompt(let prompt): + return [prompt.question, prompt.context] + case .breatheOnWatch(let nudge), + .bedtimeWindDown(let nudge), + .activitySuggestion(let nudge), + .restSuggestion(let nudge): + return [nudge.title, nudge.description] + case .morningCheckIn(let message): + return [message] + case .standardNudge: + return [] + } + } + + func testFiveProfilesStayCoherentAcrossEveryHourAndWeeklyCheckpoints() { + let referenceDate = fixedReferenceDate() + + for persona in personas { + let history = MockData.personaHistory( + persona, + days: 30, + includeStressEvent: persona == .couchPotatoMale || persona == .overweightFemale + ) + + XCTAssertEqual(history.count, 30, "\(persona.rawValue): expected 30 days of history") + + for dayIndex in (history.count - 7)..= 14 { + let correlations = correlationEngine.analyze(history: prefix) + XCTAssertFalse(correlations.isEmpty, "\(context): expected at least one correlation") + correlations.prefix(3).forEach { correlation in + assertReadable( + correlation.interpretation, + context: "\(context) correlation \(correlation.factorName)" + ) + } + } + + XCTAssertEqual(hourly.count, 24, "\(context): expected 24 hourly stress points") + XCTAssertEqual(hourly.map { $0.hour }, Array(0..<24), "\(context): expected hour-by-hour coverage") + + for hour in 0..<24 { + let actions = scheduler.recommendActions( + stressPoints: trendPoints, + trendDirection: trendDirection, + todaySnapshot: current, + patterns: sleepPatterns, + currentHour: hour, + readinessGate: readiness.level + ) + + XCTAssertFalse(actions.isEmpty, "\(context) hour \(hour): expected at least one action") + + for action in actions { + let fragments = actionTextFragments(action) + for fragment in fragments { + assertReadable(fragment, context: "\(context) hour \(hour) action") + } + + if case .morningCheckIn = action { + XCTAssertLessThan(hour, 12, "\(context) hour \(hour): morning check-in should only appear before noon") + } + } + } + } + } + } +} diff --git a/apps/HeartCoach/Tests/ProactiveNotificationTests.swift b/apps/HeartCoach/Tests/ProactiveNotificationTests.swift index d3a746c..26b6327 100644 --- a/apps/HeartCoach/Tests/ProactiveNotificationTests.swift +++ b/apps/HeartCoach/Tests/ProactiveNotificationTests.swift @@ -15,17 +15,33 @@ final class ProactiveNotificationTests: XCTestCase { private var localStore: LocalStore! private var service: ProactiveNotificationService! + private var suiteName: String! private let config = ProactiveNotificationConfig() override func setUp() { super.setUp() - localStore = LocalStore() + suiteName = "ProactiveNotificationTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) ?? .standard + defaults.removePersistentDomain(forName: suiteName) + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + localStore = LocalStore(defaults: defaults) service = ProactiveNotificationService( localStore: localStore, config: config ) } + override func tearDown() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + if let suiteName { + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + } + suiteName = nil + service = nil + localStore = nil + super.tearDown() + } + // MARK: - 1. Morning Readiness Briefing func testMorningBriefing_firesWhenEligible() async { @@ -289,7 +305,7 @@ final class ProactiveNotificationTests: XCTestCase { // Smoke test: schedule a morning briefing and check it logged await service.scheduleMorningBriefing( readinessScore: 45, - readinessLevel: .low, + readinessLevel: .recovering, topReason: "HRV dropped and sleep was short.", snapshotDate: Date() ) diff --git a/apps/HeartCoach/Tests/StressEngineTests.swift b/apps/HeartCoach/Tests/StressEngineTests.swift index 30c8ab9..8c22601 100644 --- a/apps/HeartCoach/Tests/StressEngineTests.swift +++ b/apps/HeartCoach/Tests/StressEngineTests.swift @@ -145,6 +145,26 @@ final class StressEngineTests: XCTestCase { XCTAssertTrue(points.isEmpty) } + func testHourlyStressForDay_missingHRVUsesRecentFallback() { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + let snapshots = [ + HeartSnapshot(date: twoDaysAgo, restingHeartRate: 61, hrvSDNN: 46, sleepHours: 7.1), + HeartSnapshot(date: yesterday, restingHeartRate: 60, hrvSDNN: 49, sleepHours: 7.4), + HeartSnapshot(date: today, restingHeartRate: 62, hrvSDNN: nil, sleepHours: 6.8), + ] + + let points = engine.hourlyStressForDay( + snapshots: snapshots, + date: today + ) + + XCTAssertEqual(points.count, 24) + XCTAssertEqual(points.map(\.hour), Array(0..<24)) + } + // MARK: - Trend Direction func testTrendDirection_risingScores_returnsRising() {