Files
Sportstime/SportsTimeTests/Progress/AchievementEngineTests.swift
Trey t 5c13650742 fix: resolve specificStadium achievement ID mismatch
The Green Monster (Fenway) and Ivy League (Wrigley) achievements
weren't working because:
1. Symbolic IDs use lowercase sport (stadium_mlb_bos)
2. Sport enum uses uppercase raw values (MLB)
3. Visits store stadium UUIDs, not symbolic IDs

Added resolveSymbolicStadiumId() helper that:
- Uppercases the sport string before Sport(rawValue:)
- Looks up team by abbreviation and sport
- Returns the team's stadiumId as UUID string

Also fixed:
- getStadiumIdsForLeague returns UUID strings (not symbolic IDs)
- AchievementProgress.isEarned computed from progress OR stored record
- getStadiumIdsForDivision queries CanonicalTeam properly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:22:29 -06:00

487 lines
18 KiB
Swift

//
// 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!
// Test UUIDs for stadiums
let fenwayUUID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
let wrigleyUUID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
let msgUUID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
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: "stadium_mlb_bos",
uuid: fenwayUUID,
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: "stadium_mlb_chc",
uuid: wrigleyUUID,
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: "stadium_nba_nyk",
uuid: msgUUID,
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 actual stadium UUID format
let visit = StadiumVisit(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
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(
canonicalStadiumId: wrigleyUUID.uuidString,
stadiumUUID: wrigleyUUID,
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(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
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 tdGardenUUID = UUID()
let tdGarden = CanonicalStadium(
canonicalId: "stadium_nhl_bos",
uuid: tdGardenUUID,
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(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
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(
canonicalStadiumId: msgUUID.uuidString,
stadiumUUID: msgUUID,
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(
canonicalStadiumId: tdGardenUUID.uuidString,
stadiumUUID: tdGardenUUID,
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")
}
}