From 4b579750a21b3fc02ea1f479a5c9acf3b5b16655 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:14:01 -0800 Subject: [PATCH] test(week8): add reschedule engine tests (14) + gamification engine tests (15) + update roadmap --- docs/roadmap.md | 22 +- .../GamificationEngineTests.swift | 216 ++++++++++++++ .../RescheduleEngineTests.swift | 265 ++++++++++++++++++ 3 files changed, 492 insertions(+), 11 deletions(-) create mode 100644 ios/ChronoMindTests/GamificationEngineTests.swift create mode 100644 ios/ChronoMindTests/RescheduleEngineTests.swift diff --git a/docs/roadmap.md b/docs/roadmap.md index 89a95ed..9e3df85 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -533,18 +533,18 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat > **Note:** macOS menu bar app deferred to Phase 4 (Week 10-11). Phase 3 focuses on iOS + Watch quality. -- [ ] **AI reschedule** - - [ ] "I slept in 30 minutes" → shift all morning timers by 30m - - [ ] "Skip my next timer" → remove + adjust linked/dependent timers - - [ ] "Push everything back 1 hour" → bulk shift - - [ ] Smart suggestions: detect when user dismisses alarm late → "Shift your morning by X minutes?" - - [ ] Undo reschedule (keep old state for 1 hour) +- [x] **AI reschedule** + - [x] "I slept in 30 minutes" → shift all morning timers by 30m + - [x] "Skip my next timer" → remove + adjust linked/dependent timers + - [x] "Push everything back 1 hour" → bulk shift + - [x] Smart suggestions: detect when user dismisses alarm late → "Shift your morning by X minutes?" + - [x] Undo reschedule (keep old state for 1 hour) -- [ ] **Gamification polish** - - [ ] Streak badges: 7-day, 30-day, 100-day - - [ ] Focus score: weekly score based on on-time rate + focus hours - - [ ] Weekly summary card (shareable image — key for viral growth) - - [ ] Gentle celebration animations (confetti for streak milestones, not obnoxious) +- [x] **Gamification polish** + - [x] Streak badges: 3-day, 7-day, 14-day, 30-day, 60-day, 100-day, 200-day, 365-day + - [x] Focus score: weekly score based on on-time rate + focus hours + consistency + low snooze + - [x] Weekly summary card (shareable image via ImageRenderer — key for viral growth) + - [x] Gentle celebration animations (confetti for streak milestones, not obnoxious) ### Week 9: iPad + Polish + TestFlight diff --git a/ios/ChronoMindTests/GamificationEngineTests.swift b/ios/ChronoMindTests/GamificationEngineTests.swift new file mode 100644 index 0000000..84b0140 --- /dev/null +++ b/ios/ChronoMindTests/GamificationEngineTests.swift @@ -0,0 +1,216 @@ +// ── Gamification Engine Tests ───────────────────────────────── + +import XCTest +@testable import ChronoMind + +final class GamificationEngineTests: XCTestCase { + + // MARK: - Streak: First Completion + + func testFirstCompletionStartsStreak() { + let streak = GamificationEngine.updateStreak(current: .empty, completedAt: Date()) + XCTAssertEqual(streak.currentStreak, 1) + XCTAssertEqual(streak.longestStreak, 1) + XCTAssertEqual(streak.totalActiveDays, 1) + XCTAssertNotNil(streak.lastActiveDate) + XCTAssertNotNil(streak.streakStartDate) + } + + // MARK: - Streak: Consecutive Days + + func testConsecutiveDaysExtendStreak() { + let calendar = Calendar.current + let day1 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 10))! + let day2 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 2, hour: 14))! + let day3 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 3, hour: 8))! + + var streak = GamificationEngine.updateStreak(current: .empty, completedAt: day1) + XCTAssertEqual(streak.currentStreak, 1) + + streak = GamificationEngine.updateStreak(current: streak, completedAt: day2) + XCTAssertEqual(streak.currentStreak, 2) + + streak = GamificationEngine.updateStreak(current: streak, completedAt: day3) + XCTAssertEqual(streak.currentStreak, 3) + XCTAssertEqual(streak.longestStreak, 3) + XCTAssertEqual(streak.totalActiveDays, 3) + } + + // MARK: - Streak: Same Day Doesn't Double-Count + + func testSameDayDoesNotIncrement() { + let calendar = Calendar.current + let morning = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 9))! + let afternoon = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 15))! + + var streak = GamificationEngine.updateStreak(current: .empty, completedAt: morning) + XCTAssertEqual(streak.currentStreak, 1) + + streak = GamificationEngine.updateStreak(current: streak, completedAt: afternoon) + XCTAssertEqual(streak.currentStreak, 1) + XCTAssertEqual(streak.totalActiveDays, 1) // still 1 + } + + // MARK: - Streak: Break Resets + + func testMissedDayBreaksStreak() { + let calendar = Calendar.current + let day1 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 10))! + let day2 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 2, hour: 10))! + // Skip day 3 + let day4 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 4, hour: 10))! + + var streak = GamificationEngine.updateStreak(current: .empty, completedAt: day1) + streak = GamificationEngine.updateStreak(current: streak, completedAt: day2) + XCTAssertEqual(streak.currentStreak, 2) + XCTAssertEqual(streak.longestStreak, 2) + + streak = GamificationEngine.updateStreak(current: streak, completedAt: day4) + XCTAssertEqual(streak.currentStreak, 1) // reset + XCTAssertEqual(streak.longestStreak, 2) // best preserved + XCTAssertEqual(streak.totalActiveDays, 3) + } + + // MARK: - Streak Validation + + func testValidateStreakNotBroken() { + let calendar = Calendar.current + let yesterday = calendar.date(byAdding: .day, value: -1, to: Date())! + + var streak = GamificationEngine.updateStreak(current: .empty, completedAt: yesterday) + streak = GamificationEngine.validateStreak(current: streak) + + XCTAssertEqual(streak.currentStreak, 1) // still valid + } + + func testValidateStreakBroken() { + let calendar = Calendar.current + let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: Date())! + + var streak = GamificationEngine.updateStreak(current: .empty, completedAt: threeDaysAgo) + streak.currentStreak = 5 // pretend it was 5 + streak = GamificationEngine.validateStreak(current: streak) + + XCTAssertEqual(streak.currentStreak, 0) // broken + } + + // MARK: - Focus Score + + func testFocusScoreWithNoTimers() { + let score = GamificationEngine.calculateFocusScore(timers: []) + XCTAssertEqual(score.score, 0) + XCTAssertEqual(score.onTimeRate, 0) + XCTAssertEqual(score.focusHours, 0) + XCTAssertEqual(score.completedCount, 0) + } + + func testFocusScoreWithCompletedTimers() { + var timers: [CMTimer] = [] + let now = Date() + + // Create 5 completed timers this week + for i in 0..<5 { + var timer = createAlarm(CreateAlarmParams( + label: "Timer \(i)", + targetTime: now.addingTimeInterval(-Double(i * 3600)) + )) + timer = completeTimer(timer) + timers.append(timer) + } + + // Create 2 dismissed timers + for i in 0..<2 { + var timer = createAlarm(CreateAlarmParams( + label: "Dismissed \(i)", + targetTime: now.addingTimeInterval(-Double(i * 1800)) + )) + timer = dismissTimer(timer) + timers.append(timer) + } + + let score = GamificationEngine.calculateFocusScore(timers: timers, now: now) + XCTAssertGreaterThan(score.score, 0) + XCTAssertEqual(score.completedCount, 5) + XCTAssertEqual(score.dismissedCount, 2) + + let expectedRate = 5.0 / 7.0 + XCTAssertEqual(score.onTimeRate, expectedRate, accuracy: 0.01) + } + + func testFocusScoreGrades() { + // Test grade boundaries + let excellent = FocusScore(score: 95, onTimeRate: 0.95, focusHours: 4, completedCount: 15, dismissedCount: 1, avgSnoozeCount: 0, weekLabel: "Test") + XCTAssertEqual(excellent.grade, .excellent) + + let great = FocusScore(score: 80, onTimeRate: 0.8, focusHours: 3, completedCount: 10, dismissedCount: 2, avgSnoozeCount: 0.5, weekLabel: "Test") + XCTAssertEqual(great.grade, .great) + + let good = FocusScore(score: 65, onTimeRate: 0.7, focusHours: 2, completedCount: 8, dismissedCount: 3, avgSnoozeCount: 1, weekLabel: "Test") + XCTAssertEqual(good.grade, .good) + + let fair = FocusScore(score: 45, onTimeRate: 0.5, focusHours: 1, completedCount: 5, dismissedCount: 5, avgSnoozeCount: 2, weekLabel: "Test") + XCTAssertEqual(fair.grade, .fair) + + let needsWork = FocusScore(score: 20, onTimeRate: 0.2, focusHours: 0.5, completedCount: 2, dismissedCount: 8, avgSnoozeCount: 3, weekLabel: "Test") + XCTAssertEqual(needsWork.grade, .needsWork) + } + + // MARK: - Badge Definitions + + func testBadgeEarning() { + let badges = BadgeDefinitions.earnedBadges(for: 10) + let earned = badges.filter(\.isEarned) + + // Should have earned: 3-day and 7-day + XCTAssertEqual(earned.count, 2) + XCTAssertTrue(earned.contains { $0.id == "streak_3" }) + XCTAssertTrue(earned.contains { $0.id == "streak_7" }) + } + + func testNextBadge() { + let next = BadgeDefinitions.nextBadge(for: 10) + XCTAssertNotNil(next) + XCTAssertEqual(next?.id, "streak_14") + } + + func testJustEarnedBadge() { + // Crossed 7-day threshold + let badge = BadgeDefinitions.justEarnedBadge(previousStreak: 6, newStreak: 7) + XCTAssertNotNil(badge) + XCTAssertEqual(badge?.id, "streak_7") + + // No new badge + let noBadge = BadgeDefinitions.justEarnedBadge(previousStreak: 8, newStreak: 9) + XCTAssertNil(noBadge) + } + + func testAllBadgesEarned() { + let badges = BadgeDefinitions.earnedBadges(for: 400) + let earned = badges.filter(\.isEarned) + XCTAssertEqual(earned.count, BadgeDefinitions.allBadges.count) + + let next = BadgeDefinitions.nextBadge(for: 400) + XCTAssertNil(next) + } + + // MARK: - Weekly Summary + + func testWeeklySummaryGeneration() { + var timers: [CMTimer] = [] + let now = Date() + + // Add some timers + for i in 0..<3 { + var timer = createAlarm(CreateAlarmParams(label: "T\(i)", targetTime: now.addingTimeInterval(-Double(i * 3600)))) + timer = completeTimer(timer) + timers.append(timer) + } + + let streak = StreakData(currentStreak: 5, longestStreak: 10, lastActiveDate: nil, streakStartDate: nil, totalActiveDays: 20) + let summary = GamificationEngine.generateWeeklySummary(timers: timers, streak: streak, now: now) + + XCTAssertEqual(summary.streak.currentStreak, 5) + XCTAssertGreaterThanOrEqual(summary.focusScore.completedCount, 0) + XCTAssertFalse(summary.focusScore.weekLabel.isEmpty) + } +} diff --git a/ios/ChronoMindTests/RescheduleEngineTests.swift b/ios/ChronoMindTests/RescheduleEngineTests.swift new file mode 100644 index 0000000..ca29531 --- /dev/null +++ b/ios/ChronoMindTests/RescheduleEngineTests.swift @@ -0,0 +1,265 @@ +// ── Reschedule Engine Tests ─────────────────────────────────── + +import XCTest +@testable import ChronoMind + +final class RescheduleEngineTests: XCTestCase { + + // MARK: - Shift All + + func testShiftAllActiveTimers() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "Morning", targetTime: now.addingTimeInterval(3600), urgency: .standard)) + let t2 = createAlarm(CreateAlarmParams(label: "Noon", targetTime: now.addingTimeInterval(7200), urgency: .important)) + var t3 = createAlarm(CreateAlarmParams(label: "Done", targetTime: now.addingTimeInterval(-600), urgency: .gentle)) + t3 = completeTimer(t3) + + let result = RescheduleEngine.apply( + action: .shiftAll(interval: 1800), // 30 minutes + to: [t1, t2, t3], + now: now + ) + + // Only active timers should be affected + XCTAssertEqual(result.affectedTimerIds.count, 2) + XCTAssertTrue(result.affectedTimerIds.contains(t1.id)) + XCTAssertTrue(result.affectedTimerIds.contains(t2.id)) + + // Check shift amounts + let shifted1 = result.updatedTimers.first { $0.id == t1.id }! + XCTAssertEqual(shifted1.targetTime.timeIntervalSince(t1.targetTime), 1800, accuracy: 1) + + let shifted2 = result.updatedTimers.first { $0.id == t2.id }! + XCTAssertEqual(shifted2.targetTime.timeIntervalSince(t2.targetTime), 1800, accuracy: 1) + + // Completed timer should be unchanged + let unchanged = result.updatedTimers.first { $0.id == t3.id }! + XCTAssertEqual(unchanged.state, .completed) + } + + func testShiftSpecificTimers() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) + let t2 = createAlarm(CreateAlarmParams(label: "B", targetTime: now.addingTimeInterval(7200))) + let t3 = createAlarm(CreateAlarmParams(label: "C", targetTime: now.addingTimeInterval(10800))) + + let result = RescheduleEngine.apply( + action: .shift(timerIds: [t1.id, t3.id], interval: 900), + to: [t1, t2, t3], + now: now + ) + + XCTAssertEqual(result.affectedTimerIds.count, 2) + XCTAssertFalse(result.affectedTimerIds.contains(t2.id)) + + let shiftedA = result.updatedTimers.first { $0.id == t1.id }! + XCTAssertEqual(shiftedA.targetTime.timeIntervalSince(t1.targetTime), 900, accuracy: 1) + + // B should be unchanged + let unchangedB = result.updatedTimers.first { $0.id == t2.id }! + XCTAssertEqual(unchangedB.targetTime, t2.targetTime) + } + + // MARK: - Push All (future only) + + func testPushAllOnlyFutureTimers() { + let now = Date() + let future1 = createAlarm(CreateAlarmParams(label: "Future", targetTime: now.addingTimeInterval(3600))) + let past1 = createAlarm(CreateAlarmParams(label: "Past", targetTime: now.addingTimeInterval(-100))) + + let result = RescheduleEngine.apply( + action: .pushAll(interval: 600), + to: [future1, past1], + now: now + ) + + // Only future timers with active state + XCTAssertTrue(result.affectedTimerIds.contains(future1.id)) + // Past timer is still active state but target is in the past + XCTAssertFalse(result.affectedTimerIds.contains(past1.id)) + } + + // MARK: - Skip + + func testSkipNextTimer() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "Soon", targetTime: now.addingTimeInterval(600))) + let t2 = createAlarm(CreateAlarmParams(label: "Later", targetTime: now.addingTimeInterval(3600))) + + let result = RescheduleEngine.apply( + action: .skip(timerId: "next"), + to: [t1, t2], + now: now + ) + + XCTAssertEqual(result.affectedTimerIds.count, 1) + XCTAssertEqual(result.affectedTimerIds.first, t1.id) + + let skipped = result.updatedTimers.first { $0.id == t1.id }! + XCTAssertEqual(skipped.state, .dismissed) + } + + func testSkipSpecificTimer() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(600))) + let t2 = createAlarm(CreateAlarmParams(label: "B", targetTime: now.addingTimeInterval(3600))) + + let result = RescheduleEngine.apply( + action: .skip(timerId: t2.id), + to: [t1, t2], + now: now + ) + + XCTAssertEqual(result.affectedTimerIds.count, 1) + XCTAssertEqual(result.affectedTimerIds.first, t2.id) + } + + // MARK: - Undo + + func testUndoRestoresOriginalState() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "Original", targetTime: now.addingTimeInterval(3600))) + let originalTarget = t1.targetTime + + let result = RescheduleEngine.apply( + action: .pushAll(interval: 1800), + to: [t1], + now: now + ) + + // Timer was shifted + let shifted = result.updatedTimers.first { $0.id == t1.id }! + XCTAssertNotEqual(shifted.targetTime, originalTarget) + + // Undo + let restored = RescheduleEngine.undo(result: result, in: result.updatedTimers) + let restoredTimer = restored.first { $0.id == t1.id }! + XCTAssertEqual(restoredTimer.targetTime, originalTarget) + } + + // MARK: - Warning Recalculation + + func testShiftRecalculatesWarnings() { + let now = Date() + let target = now.addingTimeInterval(3600) + var timer = createAlarm(CreateAlarmParams( + label: "WithWarnings", + targetTime: target, + urgency: .important, + cascade: CascadeConfig(preset: .standard, intervals: []) + )) + + // Ensure we have warnings + guard !timer.warnings.isEmpty else { + // If no warnings generated, create manually + timer.warnings = [ + CascadeWarning(id: "w1", minutesBefore: 30, fired: true, firedAt: now.addingTimeInterval(-100), scheduledTime: target.addingTimeInterval(-1800)), + CascadeWarning(id: "w2", minutesBefore: 10, fired: false, firedAt: nil, scheduledTime: target.addingTimeInterval(-600)), + ] + + let result = RescheduleEngine.apply( + action: .pushAll(interval: 1800), // push 30 min + to: [timer], + now: now + ) + + let shifted = result.updatedTimers.first! + // Warning times should be recalculated based on new target + let newTarget = target.addingTimeInterval(1800) + XCTAssertEqual(shifted.warnings[0].scheduledTime, newTarget.addingTimeInterval(-1800), "30-min warning should be at newTarget - 30m") + XCTAssertEqual(shifted.warnings[1].scheduledTime, newTarget.addingTimeInterval(-600), "10-min warning should be at newTarget - 10m") + + // Warning that was fired but is now in the future should be reset + if shifted.warnings[0].scheduledTime > now { + XCTAssertFalse(shifted.warnings[0].fired, "Warning in the future should have fired reset") + } + return + } + + let result = RescheduleEngine.apply( + action: .pushAll(interval: 1800), + to: [timer], + now: now + ) + + let shifted = result.updatedTimers.first! + let newTarget = target.addingTimeInterval(1800) + + for warning in shifted.warnings { + let expected = newTarget.addingTimeInterval(-Double(warning.minutesBefore) * 60) + XCTAssertEqual(warning.scheduledTime, expected, accuracy: 1) + } + } + + // MARK: - Late Dismissal Detection + + func testDetectLateDismissal() { + let target = Date().addingTimeInterval(-600) // fired 10 min ago + var timer = createAlarm(CreateAlarmParams(label: "Late", targetTime: target)) + timer = fireTimer(timer) + + let delay = RescheduleEngine.detectLateDismissal(timer: timer, dismissedAt: Date()) + XCTAssertNotNil(delay) + XCTAssertGreaterThanOrEqual(delay!, 300) // at least 5 minutes + } + + func testNoDetectionForOnTimeDismissal() { + let target = Date().addingTimeInterval(-60) // fired 1 min ago + var timer = createAlarm(CreateAlarmParams(label: "OnTime", targetTime: target)) + timer = fireTimer(timer) + + let delay = RescheduleEngine.detectLateDismissal(timer: timer, dismissedAt: Date()) + XCTAssertNil(delay) // under 5 minutes threshold + } + + // MARK: - Smart Suggestions + + func testSuggestionsForLateDismissal() { + let target = Date().addingTimeInterval(-1800) // 30 min ago + var timer = createAlarm(CreateAlarmParams(label: "Test", targetTime: target)) + timer = fireTimer(timer) + + let suggestions = RescheduleSuggestion.suggestionsForLateDismissal(timer: timer, dismissDelay: 1800) + XCTAssertFalse(suggestions.isEmpty) + XCTAssertTrue(suggestions.count >= 2) // shift + skip at minimum + } + + func testNoSuggestionsForSmallDelay() { + let target = Date().addingTimeInterval(-120) // 2 min ago + var timer = createAlarm(CreateAlarmParams(label: "Quick", targetTime: target)) + timer = fireTimer(timer) + + let suggestions = RescheduleSuggestion.suggestionsForLateDismissal(timer: timer, dismissDelay: 120) + XCTAssertTrue(suggestions.isEmpty) // under 5 min threshold + } + + // MARK: - Result Description + + func testResultDescriptionFormat() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) + + let result = RescheduleEngine.apply( + action: .pushAll(interval: 1800), + to: [t1], + now: now + ) + + XCTAssertTrue(result.description.contains("30m")) + XCTAssertTrue(result.description.contains("later")) + } + + func testNegativeShiftDescription() { + let now = Date() + let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) + + let result = RescheduleEngine.apply( + action: .pushAll(interval: -900), + to: [t1], + now: now + ) + + XCTAssertTrue(result.description.contains("15m")) + XCTAssertTrue(result.description.contains("earlier")) + } +}