// // 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: UUID = UUID(), 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: UUID = UUID(), stadiumId: UUID, homeTeamId: UUID = UUID(), awayTeamId: UUID = UUID(), 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, stadiums: [UUID: Stadium], teams: [UUID: 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 = UUID() let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) let stadiums = [stadiumId: stadium] let gameId = UUID() 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 = UUID() let milwaukeeId = UUID() let stLouisId = UUID() // 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 = UUID() let game2Id = UUID() let game3Id = UUID() 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 = UUID() let bostonId = UUID() // 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 = UUID() let game2Id = UUID() // 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 = UUID() 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 = UUID() let atlantaId = UUID() // 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 = UUID() let game2Id = UUID() // 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: [UUID: CLLocationCoordinate2D] = [:] for stop in firstOption.stops { if let coord = stop.coordinate { stopCoordinates[stop.id] = 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 } 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: [UUID: CLLocationCoordinate2D] = [:] for stop in firstOption.stops { if let coord = stop.coordinate { stopCoordinates[stop.id] = coord } } // Check that there's no obviously better route (10% threshold) guard stopCoordinates.count >= 2 else { return } let stopIds = firstOption.stops.map { $0.id } 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") } } }