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>
487 lines
18 KiB
Swift
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")
|
|
}
|
|
}
|