Files
Sportstime/SportsTimeTests/Services/AchievementEngineTests.swift
Trey t 8162b4a029 refactor(tests): TDD rewrite of all unit tests with spec documentation
Complete rewrite of unit test suite using TDD methodology:

Planning Engine Tests:
- GameDAGRouterTests: Beam search, anchor games, transitions
- ItineraryBuilderTests: Stop connection, validators, EV enrichment
- RouteFiltersTests: Region, time window, scoring filters
- ScenarioA/B/C/D PlannerTests: All planning scenarios
- TravelEstimatorTests: Distance, duration, travel days
- TripPlanningEngineTests: Orchestration, caching, preferences

Domain Model Tests:
- AchievementDefinitionsTests, AnySportTests, DivisionTests
- GameTests, ProgressTests, RegionTests, StadiumTests
- TeamTests, TravelSegmentTests, TripTests, TripPollTests
- TripPreferencesTests, TripStopTests, SportTests

Service Tests:
- FreeScoreAPITests, RouteDescriptionGeneratorTests
- SuggestedTripsGeneratorTests

Export Tests:
- ShareableContentTests (card types, themes, dimensions)

Bug fixes discovered through TDD:
- ShareCardDimensions: mapSnapshotSize exceeded available width (960x480)
- ScenarioBPlanner: Added anchor game validation filter

All tests include:
- Specification tests (expected behavior)
- Invariant tests (properties that must always hold)
- Edge case tests (boundary conditions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:07:41 -06:00

269 lines
9.3 KiB
Swift

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