Files
Sportstime/SportsTimeTests/Planning/ScenarioBPlannerTests.swift
Trey t 1703ca5b0f refactor: change domain model IDs from UUID to String canonical IDs
This refactor fixes the achievement system by using stable canonical string
IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures
stadium mappings for achievements are consistent across app launches and
CloudKit sync operations.

Changes:
- Stadium, Team, Game: id property changed from UUID to String
- Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums
- CKModels: removed UUID parsing, use canonical IDs directly
- AchievementEngine: now matches against canonical stadium IDs
- All test files updated to use String IDs instead of UUID()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:24:33 -06:00

497 lines
20 KiB
Swift

//
// ScenarioBPlannerTests.swift
// SportsTimeTests
//
// Phase 5: ScenarioBPlanner Tests
// Scenario B: User selects specific games (must-see), planner builds route.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ScenarioBPlanner Tests", .serialized)
struct ScenarioBPlannerTests {
// MARK: - Test Fixtures
private let calendar = Calendar.current
private let planner = ScenarioBPlanner()
/// Creates a date with specific year/month/day/hour
private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date {
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = hour
components.minute = 0
return calendar.date(from: components)!
}
/// Creates a stadium at a known location
private func makeStadium(
id: String = "stadium_test_\(UUID().uuidString)",
city: String,
lat: Double,
lon: Double,
sport: Sport = .mlb
) -> Stadium {
Stadium(
id: id,
name: "\(city) Stadium",
city: city,
state: "ST",
latitude: lat,
longitude: lon,
capacity: 40000,
sport: sport
)
}
/// Creates a game at a stadium
private func makeGame(
id: String = "game_test_\(UUID().uuidString)",
stadiumId: String,
homeTeamId: String = "team_test_\(UUID().uuidString)",
awayTeamId: String = "team_test_\(UUID().uuidString)",
dateTime: Date,
sport: Sport = .mlb
) -> Game {
Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: "2026"
)
}
/// Creates a PlanningRequest for Scenario B (must-see games mode)
private func makePlanningRequest(
startDate: Date,
endDate: Date,
allGames: [Game],
mustSeeGameIds: Set<String>,
stadiums: [String: Stadium],
teams: [String: Team] = [:],
allowRepeatCities: Bool = true,
numberOfDrivers: Int = 1,
maxDrivingHoursPerDriver: Double = 8.0
) -> PlanningRequest {
let preferences = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: mustSeeGameIds,
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
allowRepeatCities: allowRepeatCities
)
return PlanningRequest(
preferences: preferences,
availableGames: allGames,
teams: teams,
stadiums: stadiums
)
}
// MARK: - 5A: Valid Inputs
@Test("5.1 - Single must-see game returns trip with that game")
func test_mustSeeGames_SingleGame_ReturnsTripWithThatGame() {
// Setup: Single must-see game
let stadiumId = "stadium_test_\(UUID().uuidString)"
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let gameId = "game_test_\(UUID().uuidString)"
let game = makeGame(id: gameId, stadiumId: stadiumId, dateTime: makeDate(day: 10, hour: 19))
let request = makePlanningRequest(
startDate: makeDate(day: 5, hour: 0),
endDate: makeDate(day: 15, hour: 23),
allGames: [game],
mustSeeGameIds: [gameId],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with single must-see game")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
#expect(firstOption.totalGames >= 1, "Should have at least the must-see game")
let allGameIds = firstOption.stops.flatMap { $0.games }
#expect(allGameIds.contains(gameId), "Must-see game must be in the itinerary")
}
}
@Test("5.2 - Multiple must-see games returns optimal route")
func test_mustSeeGames_MultipleGames_ReturnsOptimalRoute() {
// Setup: 3 must-see games in nearby cities (all Central region for single-region search)
// Region boundary: Central is -110 to -85 longitude
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
let stLouisId = "stadium_stlouis_\(UUID().uuidString)"
// All cities in Central region (longitude between -110 and -85)
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
let stLouis = makeStadium(id: stLouisId, city: "St. Louis", lat: 38.6270, lon: -90.1994)
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, stLouisId: stLouis]
let game1Id = "game_test_1_\(UUID().uuidString)"
let game2Id = "game_test_2_\(UUID().uuidString)"
let game3Id = "game_test_3_\(UUID().uuidString)"
let game1 = makeGame(id: game1Id, stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(id: game2Id, stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
let game3 = makeGame(id: game3Id, stadiumId: stLouisId, dateTime: makeDate(day: 9, hour: 19))
let request = makePlanningRequest(
startDate: makeDate(day: 4, hour: 0),
endDate: makeDate(day: 10, hour: 23),
allGames: [game1, game2, game3],
mustSeeGameIds: [game1Id, game2Id, game3Id],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with multiple must-see games")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
let allGameIds = Set(firstOption.stops.flatMap { $0.games })
#expect(allGameIds.contains(game1Id), "Must include game 1")
#expect(allGameIds.contains(game2Id), "Must include game 2")
#expect(allGameIds.contains(game3Id), "Must include game 3")
// Route should be in chronological order (respecting game times)
#expect(firstOption.stops.count >= 3, "Should have at least 3 stops for 3 games in different cities")
}
}
@Test("5.3 - Games in different cities are connected")
func test_mustSeeGames_GamesInDifferentCities_ConnectsThem() {
// Setup: 2 must-see games in distant but reachable cities
let nycId = "stadium_nyc_\(UUID().uuidString)"
let bostonId = "stadium_boston_\(UUID().uuidString)"
// NYC to Boston is ~215 miles (~4 hours driving)
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
let boston = makeStadium(id: bostonId, city: "Boston", lat: 42.3601, lon: -71.0589)
let stadiums = [nycId: nyc, bostonId: boston]
let game1Id = "game_test_1_\(UUID().uuidString)"
let game2Id = "game_test_2_\(UUID().uuidString)"
// Games 2 days apart - plenty of time to drive
let game1 = makeGame(id: game1Id, stadiumId: nycId, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(id: game2Id, stadiumId: bostonId, dateTime: makeDate(day: 7, hour: 19))
let request = makePlanningRequest(
startDate: makeDate(day: 4, hour: 0),
endDate: makeDate(day: 8, hour: 23),
allGames: [game1, game2],
mustSeeGameIds: [game1Id, game2Id],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed connecting NYC and Boston")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
let allGameIds = Set(firstOption.stops.flatMap { $0.games })
#expect(allGameIds.contains(game1Id), "Must include NYC game")
#expect(allGameIds.contains(game2Id), "Must include Boston game")
// Should have travel segment between cities
#expect(firstOption.travelSegments.count >= 1, "Should have travel segment(s)")
// Verify cities are connected in the route
let cities = firstOption.stops.map { $0.city }
#expect(cities.contains("New York"), "Route should include New York")
#expect(cities.contains("Boston"), "Route should include Boston")
}
}
// MARK: - 5B: Edge Cases
@Test("5.4 - Empty selection returns failure")
func test_mustSeeGames_EmptySelection_ThrowsError() {
// Setup: No must-see games selected
let stadiumId = "stadium_test_\(UUID().uuidString)"
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let game = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 10, hour: 19))
// Empty must-see set
let request = makePlanningRequest(
startDate: makeDate(day: 5, hour: 0),
endDate: makeDate(day: 15, hour: 23),
allGames: [game],
mustSeeGameIds: [], // Empty selection
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify: Should fail with appropriate error
#expect(!result.isSuccess, "Should fail when no games selected")
#expect(result.failure?.reason == .noValidRoutes,
"Should return noValidRoutes (no selected games)")
}
@Test("5.5 - Impossible to connect games returns failure")
func test_mustSeeGames_ImpossibleToConnect_ThrowsError() {
// Setup: Games on same day in cities ~850 miles apart (impossible in 8 hours)
// Both cities in East region (> -85 longitude) so regional search covers both
let nycId = "stadium_nyc_\(UUID().uuidString)"
let atlantaId = "stadium_atlanta_\(UUID().uuidString)"
// NYC to Atlanta is ~850 miles (~13 hours driving) - impossible with 8-hour limit
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
let atlanta = makeStadium(id: atlantaId, city: "Atlanta", lat: 33.7490, lon: -84.3880)
let stadiums = [nycId: nyc, atlantaId: atlanta]
let game1Id = "game_test_1_\(UUID().uuidString)"
let game2Id = "game_test_2_\(UUID().uuidString)"
// Same day games 6 hours apart - even if you left right after game 1,
// you can't drive 850 miles in 6 hours with 8-hour daily limit
let game1 = makeGame(id: game1Id, stadiumId: nycId, dateTime: makeDate(day: 5, hour: 13))
let game2 = makeGame(id: game2Id, stadiumId: atlantaId, dateTime: makeDate(day: 5, hour: 19))
let request = makePlanningRequest(
startDate: makeDate(day: 5, hour: 0),
endDate: makeDate(day: 5, hour: 23),
allGames: [game1, game2],
mustSeeGameIds: [game1Id, game2Id],
stadiums: stadiums,
numberOfDrivers: 1,
maxDrivingHoursPerDriver: 8.0
)
// Execute
let result = planner.plan(request: request)
// Verify: Should fail because it's impossible to connect these games
// The planner should not find any valid route containing BOTH must-see games
#expect(!result.isSuccess, "Should fail when games are impossible to connect")
// Either noValidRoutes or constraintsUnsatisfiable are acceptable
let validFailureReasons: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable]
#expect(validFailureReasons.contains(result.failure?.reason ?? .noValidRoutes),
"Should return appropriate failure reason")
}
@Test("5.6 - Max games selected handles gracefully", .timeLimit(.minutes(5)))
func test_mustSeeGames_MaxGamesSelected_HandlesGracefully() {
// Setup: Generate many games and select a large subset
let config = FixtureGenerator.Configuration(
seed: 42,
gameCount: 500,
stadiumCount: 30,
teamCount: 60,
dateRange: makeDate(day: 1, hour: 0)...makeDate(month: 8, day: 31, hour: 23),
geographicSpread: .regional // Keep games in one region for feasibility
)
let data = FixtureGenerator.generate(with: config)
// Select 50 games as must-see (a stress test for the planner)
let mustSeeGames = Array(data.games.prefix(50))
let mustSeeIds = Set(mustSeeGames.map { $0.id })
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(month: 8, day: 31, hour: 23),
allGames: data.games,
mustSeeGameIds: mustSeeIds,
stadiums: data.stadiumsById
)
// Execute with timing
let startTime = Date()
let result = planner.plan(request: request)
let elapsed = Date().timeIntervalSince(startTime)
// Verify: Should complete without crash/hang
#expect(elapsed < TestConstants.performanceTimeout,
"Should complete within performance timeout")
// Result could be success or failure depending on feasibility
// The key is that it doesn't crash or hang
if result.isSuccess {
// If successful, verify anchor games are included where possible
if let firstOption = result.options.first {
let includedGames = Set(firstOption.stops.flatMap { $0.games })
let includedMustSee = includedGames.intersection(mustSeeIds)
// Some must-see games should be included
#expect(!includedMustSee.isEmpty, "Should include some must-see games")
}
}
// Failure is also acceptable for extreme constraints
}
// MARK: - 5C: Optimality Verification
@Test("5.7 - Small input matches brute force optimal")
func test_mustSeeGames_SmallInput_MatchesBruteForceOptimal() {
// Setup: 5 must-see games (within brute force threshold of 8)
// All cities in East region (> -85 longitude) for single-region search
// Geographic progression from north to south along the East Coast
let boston = makeStadium(city: "Boston", lat: 42.3601, lon: -71.0589)
let nyc = makeStadium(city: "New York", lat: 40.7128, lon: -73.9352)
let philadelphia = makeStadium(city: "Philadelphia", lat: 39.9526, lon: -75.1652)
let baltimore = makeStadium(city: "Baltimore", lat: 39.2904, lon: -76.6122)
let dc = makeStadium(city: "Washington DC", lat: 38.9072, lon: -77.0369)
let stadiums = [
boston.id: boston,
nyc.id: nyc,
philadelphia.id: philadelphia,
baltimore.id: baltimore,
dc.id: dc
]
// Games spread over 2 weeks with clear geographic progression
let game1 = makeGame(stadiumId: boston.id, dateTime: makeDate(day: 1, hour: 19))
let game2 = makeGame(stadiumId: nyc.id, dateTime: makeDate(day: 3, hour: 19))
let game3 = makeGame(stadiumId: philadelphia.id, dateTime: makeDate(day: 6, hour: 19))
let game4 = makeGame(stadiumId: baltimore.id, dateTime: makeDate(day: 9, hour: 19))
let game5 = makeGame(stadiumId: dc.id, dateTime: makeDate(day: 12, hour: 19))
let allGames = [game1, game2, game3, game4, game5]
let mustSeeIds = Set(allGames.map { $0.id })
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
allGames: allGames,
mustSeeGameIds: mustSeeIds,
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify success
#expect(result.isSuccess, "Should succeed with 5 must-see games")
guard let firstOption = result.options.first else {
Issue.record("No options returned")
return
}
// Verify all must-see games are included
let includedGameIds = Set(firstOption.stops.flatMap { $0.games })
for gameId in mustSeeIds {
#expect(includedGameIds.contains(gameId), "All must-see games should be included")
}
// Build coordinate map for brute force verification
var stopCoordinates: [String: CLLocationCoordinate2D] = [:]
for stop in firstOption.stops {
if let coord = stop.coordinate {
stopCoordinates[stop.id.uuidString] = coord
}
}
// Only verify if we have enough stops with coordinates
guard stopCoordinates.count >= 2 && stopCoordinates.count <= TestConstants.bruteForceMaxStops else {
return
}
let stopIds = firstOption.stops.map { $0.id.uuidString }
let verificationResult = BruteForceRouteVerifier.verify(
proposedRoute: stopIds,
stops: stopCoordinates,
tolerance: 0.15 // 15% tolerance for heuristic algorithms
)
let message = verificationResult.failureMessage ?? "Route should be near-optimal"
#expect(verificationResult.isOptimal, Comment(rawValue: message))
}
@Test("5.8 - Large input has no obviously better route")
func test_mustSeeGames_LargeInput_NoObviouslyBetterRoute() {
// Setup: Generate more games than brute force can handle
let config = FixtureGenerator.Configuration(
seed: 123,
gameCount: 200,
stadiumCount: 20,
teamCount: 40,
dateRange: makeDate(day: 1, hour: 0)...makeDate(month: 7, day: 31, hour: 23),
geographicSpread: .regional
)
let data = FixtureGenerator.generate(with: config)
// Select 15 games as must-see (more than brute force threshold)
let mustSeeGames = Array(data.games.prefix(15))
let mustSeeIds = Set(mustSeeGames.map { $0.id })
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(month: 7, day: 31, hour: 23),
allGames: data.games,
mustSeeGameIds: mustSeeIds,
stadiums: data.stadiumsById
)
// Execute
let result = planner.plan(request: request)
// If planning fails, that's acceptable for complex constraints
guard result.isSuccess, let firstOption = result.options.first else {
return
}
// Verify some must-see games are included
let includedGameIds = Set(firstOption.stops.flatMap { $0.games })
let includedMustSee = includedGameIds.intersection(mustSeeIds)
#expect(!includedMustSee.isEmpty, "Should include some must-see games")
// Build coordinate map
var stopCoordinates: [String: CLLocationCoordinate2D] = [:]
for stop in firstOption.stops {
if let coord = stop.coordinate {
stopCoordinates[stop.id.uuidString] = coord
}
}
// Check that there's no obviously better route (10% threshold)
guard stopCoordinates.count >= 2 else { return }
let stopIds = firstOption.stops.map { $0.id.uuidString }
let (hasBetter, improvement) = BruteForceRouteVerifier.hasObviouslyBetterRoute(
proposedRoute: stopIds,
stops: stopCoordinates,
threshold: 0.10 // 10% improvement would be "obviously better"
)
if hasBetter, let imp = improvement {
// Only fail if the improvement is very significant
#expect(imp < 0.25, "Route should not be more than 25% suboptimal")
}
}
}