// // AchievementEngineTests.swift // SportsTimeTests // // TDD specification tests for AchievementEngine types. // import Testing import Foundation import SwiftUI @testable import SportsTime // MARK: - AchievementDelta Tests @Suite("AchievementDelta") struct AchievementDeltaTests { // MARK: - Test Data private func makeDefinition(id: String = "test_achievement") -> AchievementDefinition { AchievementDefinition( id: id, name: "Test Achievement", description: "Test description", category: .count, sport: nil, iconName: "star.fill", iconColor: .blue, requirement: .firstVisit ) } private func makeDelta( newlyEarned: [AchievementDefinition] = [], revoked: [AchievementDefinition] = [], stillEarned: [AchievementDefinition] = [] ) -> AchievementDelta { AchievementDelta( newlyEarned: newlyEarned, revoked: revoked, stillEarned: stillEarned ) } // MARK: - Specification Tests: hasChanges /// - Expected Behavior: true when newlyEarned is not empty @Test("hasChanges: true when newlyEarned not empty") func hasChanges_newlyEarned() { let delta = makeDelta(newlyEarned: [makeDefinition()]) #expect(delta.hasChanges == true) } /// - Expected Behavior: true when revoked is not empty @Test("hasChanges: true when revoked not empty") func hasChanges_revoked() { let delta = makeDelta(revoked: [makeDefinition()]) #expect(delta.hasChanges == true) } /// - Expected Behavior: false when both are empty (stillEarned doesn't count) @Test("hasChanges: false when newlyEarned and revoked both empty") func hasChanges_bothEmpty() { let delta = makeDelta(newlyEarned: [], revoked: [], stillEarned: [makeDefinition()]) #expect(delta.hasChanges == false) } /// - Expected Behavior: true when both newlyEarned and revoked have items @Test("hasChanges: true when both newlyEarned and revoked have items") func hasChanges_bothHaveItems() { let delta = makeDelta( newlyEarned: [makeDefinition(id: "new")], revoked: [makeDefinition(id: "old")] ) #expect(delta.hasChanges == true) } // MARK: - Invariant Tests /// - Invariant: hasChanges == (!newlyEarned.isEmpty || !revoked.isEmpty) @Test("Invariant: hasChanges formula is correct") func invariant_hasChangesFormula() { let testCases: [(newlyEarned: [AchievementDefinition], revoked: [AchievementDefinition])] = [ ([], []), ([makeDefinition()], []), ([], [makeDefinition()]), ([makeDefinition()], [makeDefinition()]) ] for (newlyEarned, revoked) in testCases { let delta = makeDelta(newlyEarned: newlyEarned, revoked: revoked) let expected = !newlyEarned.isEmpty || !revoked.isEmpty #expect(delta.hasChanges == expected) } } } // MARK: - AchievementProgress Tests @Suite("AchievementProgress") struct AchievementProgressTests { // MARK: - Test Data private func makeDefinition() -> AchievementDefinition { AchievementDefinition( id: "test_progress", name: "Test Progress", description: "Test", category: .count, sport: nil, iconName: "star", iconColor: .blue, requirement: .visitCount(10) ) } private func makeProgress( currentProgress: Int = 5, totalRequired: Int = 10, hasStoredAchievement: Bool = false, earnedAt: Date? = nil ) -> AchievementProgress { AchievementProgress( definition: makeDefinition(), currentProgress: currentProgress, totalRequired: totalRequired, hasStoredAchievement: hasStoredAchievement, earnedAt: earnedAt ) } // MARK: - Specification Tests: isEarned /// - Expected Behavior: true when hasStoredAchievement is true @Test("isEarned: true when hasStoredAchievement") func isEarned_hasStoredAchievement() { let progress = makeProgress(currentProgress: 5, totalRequired: 10, hasStoredAchievement: true) #expect(progress.isEarned == true) } /// - Expected Behavior: true when progress >= total (and total > 0) @Test("isEarned: true when progress equals total") func isEarned_progressEqualsTotal() { let progress = makeProgress(currentProgress: 10, totalRequired: 10, hasStoredAchievement: false) #expect(progress.isEarned == true) } /// - Expected Behavior: true when progress > total @Test("isEarned: true when progress exceeds total") func isEarned_progressExceedsTotal() { let progress = makeProgress(currentProgress: 15, totalRequired: 10, hasStoredAchievement: false) #expect(progress.isEarned == true) } /// - Expected Behavior: false when progress < total and no stored achievement @Test("isEarned: false when progress less than total") func isEarned_progressLessThanTotal() { let progress = makeProgress(currentProgress: 5, totalRequired: 10, hasStoredAchievement: false) #expect(progress.isEarned == false) } /// - Expected Behavior: false when total is 0 (edge case) @Test("isEarned: false when total is 0") func isEarned_totalZero() { let progress = makeProgress(currentProgress: 5, totalRequired: 0, hasStoredAchievement: false) #expect(progress.isEarned == false) } // MARK: - Specification Tests: progressPercentage /// - Expected Behavior: Returns current/total as Double @Test("progressPercentage: returns correct ratio") func progressPercentage_correctRatio() { let progress = makeProgress(currentProgress: 5, totalRequired: 10) #expect(progress.progressPercentage == 0.5) } @Test("progressPercentage: 100% when complete") func progressPercentage_complete() { let progress = makeProgress(currentProgress: 10, totalRequired: 10) #expect(progress.progressPercentage == 1.0) } @Test("progressPercentage: 0% when no progress") func progressPercentage_noProgress() { let progress = makeProgress(currentProgress: 0, totalRequired: 10) #expect(progress.progressPercentage == 0.0) } @Test("progressPercentage: 0 when total is 0") func progressPercentage_totalZero() { let progress = makeProgress(currentProgress: 5, totalRequired: 0) #expect(progress.progressPercentage == 0.0) } @Test("progressPercentage: can exceed 100%") func progressPercentage_exceedsHundred() { let progress = makeProgress(currentProgress: 15, totalRequired: 10) #expect(progress.progressPercentage == 1.5) } // MARK: - Specification Tests: progressText /// - Expected Behavior: "Completed" when earned @Test("progressText: Completed when earned via stored") func progressText_completedViaStored() { let progress = makeProgress(currentProgress: 5, totalRequired: 10, hasStoredAchievement: true) #expect(progress.progressText == "Completed") } @Test("progressText: Completed when earned via progress") func progressText_completedViaProgress() { let progress = makeProgress(currentProgress: 10, totalRequired: 10, hasStoredAchievement: false) #expect(progress.progressText == "Completed") } /// - Expected Behavior: "current/total" when not earned @Test("progressText: shows fraction when not earned") func progressText_showsFraction() { let progress = makeProgress(currentProgress: 5, totalRequired: 10, hasStoredAchievement: false) #expect(progress.progressText == "5/10") } @Test("progressText: shows 0/total when no progress") func progressText_zeroProgress() { let progress = makeProgress(currentProgress: 0, totalRequired: 10, hasStoredAchievement: false) #expect(progress.progressText == "0/10") } // MARK: - Specification Tests: id /// - Expected Behavior: id returns definition.id @Test("id: returns definition id") func id_returnsDefinitionId() { let progress = makeProgress() #expect(progress.id == "test_progress") } // MARK: - Invariant Tests /// - Invariant: progressPercentage == Double(current) / Double(total) when total > 0 @Test("Invariant: progressPercentage calculation correct") func invariant_progressPercentageFormula() { let testCases: [(current: Int, total: Int)] = [ (0, 10), (5, 10), (10, 10), (15, 10), (1, 3) ] for (current, total) in testCases { let progress = makeProgress(currentProgress: current, totalRequired: total) let expected = Double(current) / Double(total) #expect(abs(progress.progressPercentage - expected) < 0.0001) } } /// - Invariant: isEarned implies progressText == "Completed" @Test("Invariant: isEarned implies Completed text") func invariant_earnedImpliesCompletedText() { let earned = makeProgress(currentProgress: 10, totalRequired: 10, hasStoredAchievement: true) if earned.isEarned { #expect(earned.progressText == "Completed") } } }