// // AchievementEngineTests.swift // SportsTimeTests // // TDD tests for AchievementEngine - all achievement requirement types. // Tests the bug fix where specificStadium achievements use symbolic IDs // (e.g., "stadium_mlb_bos") that need to be resolved to actual stadium UUIDs. // import XCTest import SwiftUI import SwiftData @testable import SportsTime /// Tests for AchievementEngine that don't require full AppDataProvider setup @MainActor final class AchievementEngineTests: XCTestCase { // MARK: - Basic Tests /// Verify the AchievementRegistry contains specificStadium achievements func test_registry_containsSpecificStadiumAchievements() { let fenwayAchievement = AchievementRegistry.achievement(byId: "special_fenway") let wrigleyAchievement = AchievementRegistry.achievement(byId: "special_wrigley") let msgAchievement = AchievementRegistry.achievement(byId: "special_msg") XCTAssertNotNil(fenwayAchievement, "Green Monster achievement should exist") XCTAssertNotNil(wrigleyAchievement, "Ivy League achievement should exist") XCTAssertNotNil(msgAchievement, "MSG achievement should exist") // Verify they use specificStadium requirement if case .specificStadium(let id) = fenwayAchievement!.requirement { XCTAssertEqual(id, "stadium_mlb_bos", "Fenway should use stadium_mlb_bos") } else { XCTFail("Fenway achievement should have specificStadium requirement") } if case .specificStadium(let id) = wrigleyAchievement!.requirement { XCTAssertEqual(id, "stadium_mlb_chc", "Wrigley should use stadium_mlb_chc") } else { XCTFail("Wrigley achievement should have specificStadium requirement") } if case .specificStadium(let id) = msgAchievement!.requirement { XCTAssertEqual(id, "stadium_nba_nyk", "MSG should use stadium_nba_nyk") } else { XCTFail("MSG achievement should have specificStadium requirement") } } /// Verify all achievements are defined in registry func test_registry_containsAllAchievementTypes() { let all = AchievementRegistry.all // Should have achievements for each type let hasFirstVisit = all.contains { if case .firstVisit = $0.requirement { return true }; return false } let hasVisitCount = all.contains { if case .visitCount = $0.requirement { return true }; return false } let hasSpecificStadium = all.contains { if case .specificStadium = $0.requirement { return true }; return false } let hasMultipleLeagues = all.contains { if case .multipleLeagues = $0.requirement { return true }; return false } let hasVisitsInDays = all.contains { if case .visitsInDays = $0.requirement { return true }; return false } XCTAssertTrue(hasFirstVisit, "Registry should have firstVisit achievement") XCTAssertTrue(hasVisitCount, "Registry should have visitCount achievement") XCTAssertTrue(hasSpecificStadium, "Registry should have specificStadium achievement") XCTAssertTrue(hasMultipleLeagues, "Registry should have multipleLeagues achievement") XCTAssertTrue(hasVisitsInDays, "Registry should have visitsInDays achievement") } /// Verify AchievementProgress isEarned logic func test_achievementProgress_isEarnedCalculation() { let definition = AchievementDefinition( id: "test", name: "Test", description: "Test", category: .special, sport: nil, iconName: "star", iconColor: .orange, requirement: .visitCount(5) ) // 0/5 - not earned let progress0 = AchievementProgress( definition: definition, currentProgress: 0, totalRequired: 5, hasStoredAchievement: false, earnedAt: nil ) XCTAssertFalse(progress0.isEarned) // 3/5 - not earned let progress3 = AchievementProgress( definition: definition, currentProgress: 3, totalRequired: 5, hasStoredAchievement: false, earnedAt: nil ) XCTAssertFalse(progress3.isEarned) // 5/5 - earned (computed) let progress5 = AchievementProgress( definition: definition, currentProgress: 5, totalRequired: 5, hasStoredAchievement: false, earnedAt: nil ) XCTAssertTrue(progress5.isEarned, "5/5 should be earned") // 3/5 but has stored achievement - earned let progressStored = AchievementProgress( definition: definition, currentProgress: 3, totalRequired: 5, hasStoredAchievement: true, earnedAt: Date() ) XCTAssertTrue(progressStored.isEarned, "Should be earned if stored") } /// Verify AchievementProgress percentage calculation func test_achievementProgress_percentageCalculation() { let definition = AchievementDefinition( id: "test", name: "Test", description: "Test", category: .special, sport: nil, iconName: "star", iconColor: .orange, requirement: .visitCount(10) ) let progress = AchievementProgress( definition: definition, currentProgress: 5, totalRequired: 10, hasStoredAchievement: false, earnedAt: nil ) XCTAssertEqual(progress.progressPercentage, 0.5, accuracy: 0.001) XCTAssertEqual(progress.progressText, "5/10") } /// Verify AchievementDelta hasChanges func test_achievementDelta_hasChanges() { let definition = AchievementDefinition( id: "test", name: "Test", description: "Test", category: .special, sport: nil, iconName: "star", iconColor: .orange, requirement: .firstVisit ) let emptyDelta = AchievementDelta(newlyEarned: [], revoked: [], stillEarned: []) XCTAssertFalse(emptyDelta.hasChanges, "Empty delta should have no changes") let newEarnedDelta = AchievementDelta(newlyEarned: [definition], revoked: [], stillEarned: []) XCTAssertTrue(newEarnedDelta.hasChanges, "Delta with newly earned should have changes") let revokedDelta = AchievementDelta(newlyEarned: [], revoked: [definition], stillEarned: []) XCTAssertTrue(revokedDelta.hasChanges, "Delta with revoked should have changes") let stillEarnedDelta = AchievementDelta(newlyEarned: [], revoked: [], stillEarned: [definition]) XCTAssertFalse(stillEarnedDelta.hasChanges, "Delta with only stillEarned should have no changes") } } // MARK: - Integration Tests (require AppDataProvider) /// Integration tests that test the full achievement engine with real data @MainActor final class AchievementEngineIntegrationTests: XCTestCase { var modelContainer: ModelContainer! var modelContext: ModelContext! // Canonical IDs for test stadiums let fenwayId = "stadium_mlb_bos" let wrigleyId = "stadium_mlb_chc" let msgId = "stadium_nba_nyk" override func setUp() async throws { try await super.setUp() // Create container with ALL models the app uses let schema = Schema([ // User data models StadiumVisit.self, Achievement.self, VisitPhotoMetadata.self, CachedGameScore.self, // Canonical models CanonicalTeam.self, CanonicalStadium.self, CanonicalGame.self, LeagueStructureModel.self, TeamAlias.self, StadiumAlias.self, SyncState.self, // Trip models SavedTrip.self, TripVote.self, UserPreferences.self, CachedSchedule.self ]) let config = ModelConfiguration(isStoredInMemoryOnly: true) modelContainer = try ModelContainer(for: schema, configurations: [config]) modelContext = modelContainer.mainContext // Setup minimal test data await setupTestData() // Configure and load AppDataProvider AppDataProvider.shared.configure(with: modelContext) await AppDataProvider.shared.loadInitialData() } override func tearDown() async throws { modelContainer = nil modelContext = nil try await super.tearDown() } private func setupTestData() async { // Create Fenway Park stadium let fenway = CanonicalStadium( canonicalId: fenwayId, name: "Fenway Park", city: "Boston", state: "MA", latitude: 42.3467, longitude: -71.0972, capacity: 37755, yearOpened: 1912, sport: "mlb" ) // Create Wrigley Field stadium let wrigley = CanonicalStadium( canonicalId: wrigleyId, name: "Wrigley Field", city: "Chicago", state: "IL", latitude: 41.9484, longitude: -87.6553, capacity: 41649, yearOpened: 1914, sport: "mlb" ) // Create MSG stadium let msg = CanonicalStadium( canonicalId: msgId, name: "Madison Square Garden", city: "New York", state: "NY", latitude: 40.7505, longitude: -73.9934, capacity: 19812, yearOpened: 1968, sport: "nba" ) modelContext.insert(fenway) modelContext.insert(wrigley) modelContext.insert(msg) // Create Red Sox team (plays at Fenway) let redSox = CanonicalTeam( canonicalId: "team_mlb_bos", name: "Red Sox", abbreviation: "BOS", sport: "mlb", city: "Boston", stadiumCanonicalId: "stadium_mlb_bos" ) // Create Cubs team (plays at Wrigley) let cubs = CanonicalTeam( canonicalId: "team_mlb_chc", name: "Cubs", abbreviation: "CHC", sport: "mlb", city: "Chicago", stadiumCanonicalId: "stadium_mlb_chc" ) // Create Knicks team (plays at MSG) let knicks = CanonicalTeam( canonicalId: "team_nba_nyk", name: "Knicks", abbreviation: "NYK", sport: "nba", city: "New York", stadiumCanonicalId: "stadium_nba_nyk" ) modelContext.insert(redSox) modelContext.insert(cubs) modelContext.insert(knicks) try? modelContext.save() } /// Test: Visit Fenway Park, check Green Monster achievement progress func test_fenwayVisit_earnsGreenMonster() async throws { // Create engine let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared) // Create a visit to Fenway using the canonical stadium ID let visit = StadiumVisit( stadiumId: fenwayId, stadiumNameAtVisit: "Fenway Park", visitDate: Date(), sport: .mlb ) modelContext.insert(visit) try modelContext.save() // Get progress let progress = try await engine.getProgress() // Find Green Monster achievement let greenMonster = progress.first { $0.definition.id == "special_fenway" } XCTAssertNotNil(greenMonster, "Green Monster achievement should be in progress list") if let gm = greenMonster { XCTAssertEqual(gm.currentProgress, 1, "Progress should be 1 after visiting Fenway") XCTAssertEqual(gm.totalRequired, 1, "Total required should be 1") XCTAssertTrue(gm.isEarned, "Green Monster should be earned after visiting Fenway") } } /// Test: Visit Wrigley, check Ivy League achievement progress func test_wrigleyVisit_earnsIvyLeague() async throws { let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared) let visit = StadiumVisit( stadiumId: wrigleyId, stadiumNameAtVisit: "Wrigley Field", visitDate: Date(), sport: .mlb ) modelContext.insert(visit) try modelContext.save() let progress = try await engine.getProgress() let ivyLeague = progress.first { $0.definition.id == "special_wrigley" } XCTAssertNotNil(ivyLeague, "Ivy League achievement should exist") XCTAssertTrue(ivyLeague!.isEarned, "Ivy League should be earned after visiting Wrigley") } /// Test: No visits, specificStadium achievements show 0 progress func test_noVisits_showsZeroProgress() async throws { let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared) let progress = try await engine.getProgress() let greenMonster = progress.first { $0.definition.id == "special_fenway" } XCTAssertNotNil(greenMonster) XCTAssertEqual(greenMonster!.currentProgress, 0, "Progress should be 0 with no visits") XCTAssertFalse(greenMonster!.isEarned, "Should not be earned with no visits") } /// Test: First visit achievement func test_firstVisit_earnsAchievement() async throws { let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared) // No visits - first visit not earned var progress = try await engine.getProgress() var firstVisit = progress.first { $0.definition.id == "first_visit" } XCTAssertFalse(firstVisit!.isEarned, "First visit should not be earned initially") // Add a visit let visit = StadiumVisit( stadiumId: fenwayId, stadiumNameAtVisit: "Fenway Park", visitDate: Date(), sport: .mlb ) modelContext.insert(visit) try modelContext.save() // Now first visit should be earned progress = try await engine.getProgress() firstVisit = progress.first { $0.definition.id == "first_visit" } XCTAssertTrue(firstVisit!.isEarned, "First visit should be earned after any visit") } /// Test: Multiple leagues achievement func test_multipleLeagues_requiresThreeSports() async throws { let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared) // Add TD Garden for NHL let tdGardenId = "stadium_nhl_bos" let tdGarden = CanonicalStadium( canonicalId: tdGardenId, name: "TD Garden", city: "Boston", state: "MA", latitude: 42.3662, longitude: -71.0621, capacity: 17850, yearOpened: 1995, sport: "nhl" ) modelContext.insert(tdGarden) let bruins = CanonicalTeam( canonicalId: "team_nhl_bos", name: "Bruins", abbreviation: "BOS", sport: "nhl", city: "Boston", stadiumCanonicalId: "stadium_nhl_bos" ) modelContext.insert(bruins) try modelContext.save() // Reload data provider await AppDataProvider.shared.loadInitialData() // Visit MLB stadium only - not enough let mlbVisit = StadiumVisit( stadiumId: fenwayId, stadiumNameAtVisit: "Fenway Park", visitDate: Date(), sport: .mlb ) modelContext.insert(mlbVisit) try modelContext.save() var progress = try await engine.getProgress() var tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" } XCTAssertEqual(tripleThreat!.currentProgress, 1, "Should have 1 league after MLB visit") XCTAssertFalse(tripleThreat!.isEarned, "Triple threat needs 3 leagues") // Visit NBA stadium - still not enough let nbaVisit = StadiumVisit( stadiumId: msgId, stadiumNameAtVisit: "MSG", visitDate: Date(), sport: .nba ) modelContext.insert(nbaVisit) try modelContext.save() progress = try await engine.getProgress() tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" } XCTAssertEqual(tripleThreat!.currentProgress, 2, "Should have 2 leagues") XCTAssertFalse(tripleThreat!.isEarned, "Triple threat needs 3 leagues") // Visit NHL stadium - now earned! let nhlVisit = StadiumVisit( stadiumId: tdGardenId, stadiumNameAtVisit: "TD Garden", visitDate: Date(), sport: .nhl ) modelContext.insert(nhlVisit) try modelContext.save() progress = try await engine.getProgress() tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" } XCTAssertEqual(tripleThreat!.currentProgress, 3, "Should have 3 leagues") XCTAssertTrue(tripleThreat!.isEarned, "Triple threat should be earned with 3 leagues") } }