217 lines
8.5 KiB
Swift
217 lines
8.5 KiB
Swift
// ── 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)
|
|
}
|
|
}
|