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