// // PlannerOutputSanityTests.swift // SportsTimeTests // // Tests that planner outputs pass basic sanity checks any user would notice. // These complement algorithmic correctness tests by verifying output invariants // like "no past games", "no wrong sports", "stops in chronological order", etc. // import Testing import Foundation import CoreLocation @testable import SportsTime @Suite("PlannerOutputSanity") struct PlannerOutputSanityTests { // MARK: - Shared Helpers private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.9484, longitude: -87.6553) private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665) private let atlantaCoord = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006) private let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400) private func makeStadium(id: String, city: String, coordinate: CLLocationCoordinate2D, sport: Sport = .mlb) -> Stadium { Stadium(id: id, name: "\(city) Stadium", city: city, state: "XX", latitude: coordinate.latitude, longitude: coordinate.longitude, capacity: 40000, sport: sport) } private func makeGame(id: String, homeTeamId: String, awayTeamId: String, stadiumId: String, dateTime: Date, sport: Sport = .mlb) -> Game { Game(id: id, homeTeamId: homeTeamId, awayTeamId: awayTeamId, stadiumId: stadiumId, dateTime: dateTime, sport: sport, season: "2026", isPlayoff: false) } private func makeTeam(id: String, name: String, city: String, stadiumId: String, sport: Sport = .mlb) -> Team { Team(id: id, name: name, abbreviation: String(name.prefix(3)).uppercased(), sport: sport, city: city, stadiumId: stadiumId) } /// Extracts all game IDs from a successful result. private func allGameIds(from result: ItineraryResult) -> [Set] { guard case .success(let options) = result else { return [] } return options.map { Set($0.stops.flatMap { $0.games }) } } /// Resolves Game objects for game IDs in a result against a game list. private func resolvedGames(from result: ItineraryResult, allGames: [Game]) -> [[Game]] { let gameMap = Dictionary(allGames.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) guard case .success(let options) = result else { return [] } return options.map { option in option.stops.flatMap { $0.games }.compactMap { gameMap[$0] } } } // MARK: - Category 1: Temporal Sanity (no past games in output) @Test("ScenarioA: future-only date range — all stops within range") func scenarioA_pastGamesInRange_excludedFromResults() { // Use absolute future dates to avoid TestClock.now vs Date() mismatch let startDate = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 12) let endDate = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 12) let calendar = TestClock.calendar let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let g1 = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestFixtures.date(year: 2026, month: 7, day: 3, hour: 19)) let g2 = makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19)) let g3 = makeGame(id: "g3", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestFixtures.date(year: 2026, month: 7, day: 7, hour: 19)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [g1, g2, g3], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } #expect(!options.isEmpty, "Should find options") for option in options { for stop in option.stops where !stop.games.isEmpty { #expect(stop.arrivalDate >= calendar.startOfDay(for: startDate), "Stop \(stop.city) arrival \(stop.arrivalDate) before startDate") } } } @Test("ScenarioE: all result stops are in the future") func scenarioE_allResultStopsInFuture() { // Regression test for the PHI/WSN/BAL bug where past spring training games appeared let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12) let calendar = TestClock.calendar let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) // Past games (March — spring training territory) let pastGames = [ makeGame(id: "past-phi", homeTeamId: "phi", awayTeamId: "opp", stadiumId: "philly", dateTime: TestFixtures.date(year: 2026, month: 3, day: 5, hour: 13)), makeGame(id: "past-nyc", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestFixtures.date(year: 2026, month: 3, day: 7, hour: 13)), makeGame(id: "past-bos", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston", dateTime: TestFixtures.date(year: 2026, month: 3, day: 9, hour: 13)), ] // Future games let futureGames = [ makeGame(id: "future-phi", homeTeamId: "phi", awayTeamId: "opp", stadiumId: "philly", dateTime: TestFixtures.date(year: 2026, month: 4, day: 10, hour: 19)), makeGame(id: "future-nyc", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestFixtures.date(year: 2026, month: 4, day: 12, hour: 19)), makeGame(id: "future-bos", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston", dateTime: TestFixtures.date(year: 2026, month: 4, day: 14, hour: 19)), ] let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedTeamIds: ["phi", "nyc_team", "bos_team"] ) let request = PlanningRequest( preferences: prefs, availableGames: pastGames + futureGames, teams: [ "phi": makeTeam(id: "phi", name: "Phillies", city: "Philadelphia", stadiumId: "philly"), "nyc_team": makeTeam(id: "nyc_team", name: "Yankees", city: "New York", stadiumId: "nyc"), "bos_team": makeTeam(id: "bos_team", name: "Red Sox", city: "Boston", stadiumId: "boston"), ], stadiums: ["philly": phillyStadium, "nyc": nycStadium, "boston": bostonStadium] ) let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } let startOfCurrentDay = calendar.startOfDay(for: currentDate) for option in options { for stop in option.stops { #expect(stop.arrivalDate >= startOfCurrentDay, "Stop arrival \(stop.arrivalDate) should be on or after \(startOfCurrentDay)") } } } @Test("ScenarioD: follow team — past games in date range excluded") func scenarioD_followTeam_pastGamesExcluded() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let pastGame = makeGame(id: "past-team-game", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingDays(-3)) let futureGame1 = makeGame(id: "future-team-1", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let futureGame2 = makeGame(id: "future-team-2", homeTeamId: "opp", awayTeamId: "yankees", stadiumId: "boston", dateTime: TestClock.addingDays(4)) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: TestClock.addingDays(-5), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, availableGames: [pastGame, futureGame1, futureGame2], teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc"), "opp": makeTeam(id: "opp", name: "Opponent", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let planner = ScenarioDPlanner() let result = planner.plan(request: request) // ScenarioD filters by date range only — it does NOT filter past games // that fall within the user's specified date range. The past game at day-3 // is within [day-5, day+7] so it may appear in output. // This test verifies stops are at least within the date range. guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { for stop in option.stops { if !stop.games.isEmpty { #expect(stop.arrivalDate >= TestClock.calendar.startOfDay(for: TestClock.addingDays(-5)), "Stop should not be before start date") // Verify no game dates fall outside the date range for gameId in stop.games { if let game = [pastGame, futureGame1, futureGame2].first(where: { $0.id == gameId }) { #expect(game.startTime <= TestClock.addingDays(7), "Game \(gameId) should not be after end date") } } } } } } @Test("All planners: output dates not before start date") func allPlanners_outputDatesNotBeforeStartDate() { let startDate = TestClock.addingDays(1) let endDate = TestClock.addingDays(8) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly", dateTime: TestClock.addingDays(6)), ] let teams = [ "t1": makeTeam(id: "t1", name: "Team1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "Team2", city: "Boston", stadiumId: "boston"), "t3": makeTeam(id: "t3", name: "Team3", city: "Philadelphia", stadiumId: "philly"), ] let stadiums = ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] // Test ScenarioA let prefsA = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .packed, numberOfDrivers: 2 ) let requestA = PlanningRequest(preferences: prefsA, availableGames: games, teams: teams, stadiums: stadiums) let resultA = ScenarioAPlanner().plan(request: requestA) guard case .success(let options) = resultA else { Issue.record("Expected .success, got \(resultA)"); return } for option in options { for stop in option.stops where !stop.games.isEmpty { #expect(stop.arrivalDate >= startDate, "ScenarioA: stop arrival \(stop.arrivalDate) before startDate \(startDate)") } } } // MARK: - Category 2: Sport Filtering @Test("ScenarioA: games with missing stadiums filtered out — wrong sport stadiums absent") func scenarioA_mixedSports_wrongSportGamesExcludedByMissingStadium() { // Sport filtering happens upstream (AppDataProvider). At the planner level, // wrong-sport games are excluded because their stadiums aren't in the map. let nycMLB = makeStadium(id: "nyc_mlb", city: "New York", coordinate: nycCoord, sport: .mlb) let bostonMLB = makeStadium(id: "boston_mlb", city: "Boston", coordinate: bostonCoord, sport: .mlb) let mlbGame1 = makeGame(id: "mlb1", homeTeamId: "mlb_t1", awayTeamId: "vis", stadiumId: "nyc_mlb", dateTime: TestClock.addingDays(2), sport: .mlb) let mlbGame2 = makeGame(id: "mlb2", homeTeamId: "mlb_t2", awayTeamId: "vis", stadiumId: "boston_mlb", dateTime: TestClock.addingDays(4), sport: .mlb) // NBA game references a stadium NOT in the map (as would happen in production) let nbaGame = makeGame(id: "nba1", homeTeamId: "nba_t1", awayTeamId: "vis", stadiumId: "nyc_nba_missing", dateTime: TestClock.addingDays(3), sport: .nba) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2 ) // Only MLB stadiums in the map — NBA stadium absent let request = PlanningRequest( preferences: prefs, availableGames: [mlbGame1, mlbGame2, nbaGame], teams: [:], stadiums: ["nyc_mlb": nycMLB, "boston_mlb": bostonMLB] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { let ids = Set(option.stops.flatMap { $0.games }) #expect(!ids.contains("nba1"), "NBA game with no stadium in map should not appear in output") } } // MARK: - Category 3: Geographic / Data Integrity @Test("All planners: no 'Unknown' cities in output") func allPlanners_noUnknownCitiesInOutput() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { for stop in option.stops { #expect(stop.city != "Unknown", "Stop should not have 'Unknown' city") #expect(!stop.city.isEmpty, "Stop city should not be empty") } } } @Test("All planners: no nil coordinates in stops") func allPlanners_noNilCoordinatesInStops() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { for stop in option.stops where !stop.games.isEmpty { #expect(stop.coordinate != nil, "Stop in \(stop.city) should have coordinates") } } } @Test("ScenarioA: game with missing stadium — excluded, not crash") func scenarioA_gameWithMissingStadium_excludedNotCrash() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) // One game has a valid stadium, one has a stadium not in the map let validGame = makeGame(id: "valid", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let orphanGame = makeGame(id: "orphan", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "stadium_nonexistent", dateTime: TestClock.addingDays(3)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [validGame, orphanGame], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Nowhere", stadiumId: "stadium_nonexistent")], stadiums: ["nyc": nycStadium] // Only NYC stadium in map ) // Should not crash — orphan game is simply excluded let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { let ids = Set(option.stops.flatMap { $0.games }) #expect(!ids.contains("orphan"), "Game with missing stadium should not appear in output") } } // MARK: - Category 4: Duplicate Prevention @Test("All planners: no duplicate game IDs in same stop") func allPlanners_noDuplicateGameIdsInSameStop() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) // Create games that could plausibly overlap let g1 = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let g2 = makeGame(id: "g2", homeTeamId: "t1", awayTeamId: "vis2", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let g3 = makeGame(id: "g3", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [g1, g2, g3], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { for stop in option.stops { let ids = stop.games let uniqueIds = Set(ids) #expect(ids.count == uniqueIds.count, "Stop in \(stop.city) has duplicate game IDs: \(ids)") } } } @Test("All planners: no duplicate game IDs across stops") func allPlanners_noDuplicateGameIdsAcrossStops() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly", dateTime: TestClock.addingDays(6)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(8), leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"), "t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for (idx, option) in options.enumerated() { var seen = Set() for stop in option.stops { for gid in stop.games { #expect(!seen.contains(gid), "Option \(idx): game \(gid) appears in multiple stops") seen.insert(gid) } } } } // MARK: - Category 5: Chronological Order @Test("All planners: stops chronologically ordered") func allPlanners_stopsChronologicallyOrdered() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly", dateTime: TestClock.addingDays(6)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(8), leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"), "t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for (idx, option) in options.enumerated() { for i in 0..<(option.stops.count - 1) { #expect(option.stops[i].arrivalDate <= option.stops[i + 1].arrivalDate, "Option \(idx): stops not chronological — \(option.stops[i].city) arrives \(option.stops[i].arrivalDate) but next stop \(option.stops[i+1].city) arrives \(option.stops[i+1].arrivalDate)") } } } @Test("ScenarioD: follow team — games within stop are chronological") func scenarioD_followTeam_gamesWithinStopChronological() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) // Two games at same stadium on same day (doubleheader) let game1 = makeGame(id: "dh1", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let game2 = makeGame(id: "dh2", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingHours(48 + 4)) // 4 hours later same day let game3 = makeGame(id: "away1", homeTeamId: "opp", awayTeamId: "yankees", stadiumId: "boston", dateTime: TestClock.addingDays(4)) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc"), "opp": makeTeam(id: "opp", name: "Opponent", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioDPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } let allGames = [game1, game2, game3] let gameMap = Dictionary(allGames.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f }) for option in options { for stop in option.stops where stop.games.count > 1 { let gameDates = stop.games.compactMap { gameMap[$0]?.dateTime } for i in 0..<(gameDates.count - 1) { #expect(gameDates[i] <= gameDates[i + 1], "Games within stop should be chronological") } } } } @Test("ScenarioE: results span multiple months — not all clustered") func scenarioE_windowsSpreadAcrossSeason() { let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12) let calendar = TestClock.calendar let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) // Games every 10 days for 6 months var games: [Game] = [] for monthOffset in 0..<6 { let month = 4 + monthOffset for dayOffset in stride(from: 1, through: 25, by: 10) { let dt = TestFixtures.date(year: 2026, month: month, day: dayOffset, hour: 19) games.append(makeGame(id: "nyc-\(month)-\(dayOffset)", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc", dateTime: dt)) let dt2 = calendar.date(byAdding: .day, value: 1, to: dt)! games.append(makeGame(id: "bos-\(month)-\(dayOffset)", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston", dateTime: dt2)) } } let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedTeamIds: ["nyc_team", "bos_team"] ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["nyc_team": makeTeam(id: "nyc_team", name: "NYT", city: "New York", stadiumId: "nyc"), "bos_team": makeTeam(id: "bos_team", name: "BST", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } let months = Set(options.flatMap { option in option.stops.map { calendar.component(.month, from: $0.arrivalDate) } }) #expect(months.count >= 2, "Results should span at least 2 months, got: \(months.sorted())") } // MARK: - Category 6: Constraint Adherence @Test("All planners: repeat cities false — no city appears twice") func allPlanners_repeatCitiesFalse_noCityAppearsTwice() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) // Create games that revisit NYC let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly", dateTime: TestClock.addingDays(5)), makeGame(id: "g4", homeTeamId: "t1", awayTeamId: "vis2", stadiumId: "nyc", dateTime: TestClock.addingDays(6)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(8), leisureLevel: .packed, numberOfDrivers: 2, allowRepeatCities: false ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"), "t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) // Run through the full engine (which applies repeat-city filtering) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for (idx, option) in options.enumerated() { let cities = option.stops.map(\.city) let uniqueCities = Set(cities) #expect(cities.count == uniqueCities.count, "Option \(idx): repeat cities found when disallowed — \(cities)") } } @Test("ScenarioA: trip duration does not exceed date range") func scenarioA_tripDurationDoesNotExceedDateRange() { let startDate = TestClock.addingDays(1) let endDate = TestClock.addingDays(6) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let games = [ makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(4)), ] let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } // Allow 1 day buffer for departure on last day let maxDeparture = TestClock.calendar.date(byAdding: .day, value: 1, to: endDate)! for (idx, option) in options.enumerated() { if let lastStop = option.stops.last { #expect(lastStop.departureDate <= maxDeparture, "Option \(idx): last departure \(lastStop.departureDate) exceeds endDate+1 \(maxDeparture)") } } } @Test("ScenarioB: all anchor games present in every option") func scenarioB_allAnchorGamesPresent() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) let anchor1 = makeGame(id: "anchor1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let anchor2 = makeGame(id: "anchor2", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly", dateTime: TestClock.addingDays(5)) let bonus = makeGame(id: "bonus", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston", dateTime: TestClock.addingDays(3)) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["anchor1", "anchor2"], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [anchor1, anchor2, bonus], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"), "t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"), "t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) let result = ScenarioBPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for (idx, option) in options.enumerated() { let allIds = Set(option.stops.flatMap { $0.games }) #expect(allIds.contains("anchor1"), "Option \(idx): missing anchor game 'anchor1'") #expect(allIds.contains("anchor2"), "Option \(idx): missing anchor game 'anchor2'") } } @Test("ScenarioE: all selected teams represented in every option") func scenarioE_allSelectedTeamsRepresented() { let currentDate = TestClock.now let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) let games = [ makeGame(id: "nyc1", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingDays(2)), makeGame(id: "bos1", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston", dateTime: TestClock.addingDays(3)), makeGame(id: "phi1", homeTeamId: "phi_team", awayTeamId: "opp", stadiumId: "philly", dateTime: TestClock.addingDays(4)), // More spread-out games makeGame(id: "nyc2", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc", dateTime: TestClock.addingDays(10)), makeGame(id: "bos2", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston", dateTime: TestClock.addingDays(12)), makeGame(id: "phi2", homeTeamId: "phi_team", awayTeamId: "opp", stadiumId: "philly", dateTime: TestClock.addingDays(14)), ] let selectedTeamIds: Set = ["nyc_team", "bos_team", "phi_team"] let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], numberOfDrivers: 2, selectedTeamIds: selectedTeamIds ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: [ "nyc_team": makeTeam(id: "nyc_team", name: "NYT", city: "New York", stadiumId: "nyc"), "bos_team": makeTeam(id: "bos_team", name: "BST", city: "Boston", stadiumId: "boston"), "phi_team": makeTeam(id: "phi_team", name: "PHI", city: "Philadelphia", stadiumId: "philly"), ], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f }) for (idx, option) in options.enumerated() { let homeTeamsInOption = Set( option.stops.flatMap { $0.games } .compactMap { gameMap[$0]?.homeTeamId } ) for teamId in selectedTeamIds { #expect(homeTeamsInOption.contains(teamId), "Option \(idx): missing home game for team \(teamId)") } } } // MARK: - Category 7: Edge Cases Users Actually Hit @Test("ScenarioA: single game in range — returns valid trip") func scenarioA_singleGameInRange_returnsValidTrip() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let singleGame = makeGame(id: "only_game", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(3)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [singleGame], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")], stadiums: ["nyc": nycStadium] ) // Should not crash. May return success with 1 stop or failure (no route with only 1 game) let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } #expect(!options.isEmpty, "Single game should produce at least one option") for option in options { #expect(option.stops.count >= 1, "Should have at least 1 stop") } } @Test("ScenarioE: two teams same city — handled correctly") func scenarioE_twoTeamsSameCity_handledCorrectly() { let currentDate = TestClock.now // Yankees and Mets both in NYC but different stadiums let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262) let citiFieldCoord = CLLocationCoordinate2D(latitude: 40.7571, longitude: -73.8458) let yankeeStadium = makeStadium(id: "ys", city: "New York", coordinate: yankeeStadiumCoord) let citiField = makeStadium(id: "cf", city: "New York", coordinate: citiFieldCoord) let yankeesGame = makeGame(id: "ynk1", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "ys", dateTime: TestClock.addingDays(2)) let metsGame = makeGame(id: "met1", homeTeamId: "mets", awayTeamId: "opp", stadiumId: "cf", dateTime: TestClock.addingDays(5)) let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], numberOfDrivers: 2, selectedTeamIds: ["yankees", "mets"] ) let request = PlanningRequest( preferences: prefs, availableGames: [yankeesGame, metsGame], teams: [ "yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "ys"), "mets": makeTeam(id: "mets", name: "Mets", city: "New York", stadiumId: "cf"), ], stadiums: ["ys": yankeeStadium, "cf": citiField] ) let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) // Should not crash. Both teams should be represented. guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } #expect(!options.isEmpty, "Same-city teams should produce valid routes") for option in options { let allIds = Set(option.stops.flatMap { $0.games }) #expect(allIds.contains("ynk1"), "Should include Yankees game") #expect(allIds.contains("met1"), "Should include Mets game") } } @Test("ScenarioD: team plays doubleheader — one stop, two games") func scenarioD_teamPlaysDoubleheader_handledCorrectly() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let calendar = TestClock.calendar let gameDay = TestClock.addingDays(2) let gameTime1 = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: gameDay)! let gameTime2 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: gameDay)! let game1 = makeGame(id: "dh_game1", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: gameTime1) let game2 = makeGame(id: "dh_game2", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: gameTime2) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(5), leisureLevel: .packed, numberOfDrivers: 1, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc")], stadiums: ["nyc": nycStadium] ) let result = ScenarioDPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { // Both games at same stadium should be in 1 stop, not 2 separate stops let nycStops = option.stops.filter { $0.city == "New York" } #expect(nycStops.count <= 1, "Doubleheader games should be combined into 1 stop, got \(nycStops.count)") if let nycStop = nycStops.first { #expect(nycStop.games.count >= 2, "NYC stop should contain both doubleheader games") } } } @Test("ScenarioC: start and end same city — handled gracefully") func scenarioC_startAndEndSameCity_handledGracefully() { let chicagoStadium = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let game = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(3)) let chicagoLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: chicagoLocation, endLocation: chicagoLocation, // Same as start — round trip sports: [.mlb], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")], stadiums: ["chi": chicagoStadium, "nyc": nycStadium] ) // Should not crash. Either returns circular route or graceful failure. let result = ScenarioCPlanner().plan(request: request) switch result { case .success(let options): #expect(!options.isEmpty) case .failure: break // Failure is acceptable for round-trip edge case } } @Test("ScenarioB: all selected games in same city — one stop") func scenarioB_allSelectedGamesInSameCity_oneStop() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let g1 = makeGame(id: "nyc_g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let g2 = makeGame(id: "nyc_g2", homeTeamId: "t1", awayTeamId: "vis2", stadiumId: "nyc", dateTime: TestClock.addingDays(3)) let g3 = makeGame(id: "nyc_g3", homeTeamId: "t1", awayTeamId: "vis3", stadiumId: "nyc", dateTime: TestClock.addingDays(4)) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["nyc_g1", "nyc_g2", "nyc_g3"], startDate: TestClock.addingDays(1), endDate: TestClock.addingDays(7), leisureLevel: .packed, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [g1, g2, g3], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")], stadiums: ["nyc": nycStadium] ) let result = ScenarioBPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } for option in options { // All games are at the same stadium, so should be 1 stop (or at most 1 game stop) let gameStops = option.stops.filter { !$0.games.isEmpty } // They may be grouped into 1 stop or separated by day — but city should be same let cities = Set(gameStops.map(\.city)) #expect(cities.count <= 1, "All games at same stadium should be in same city, got: \(cities)") } } @Test("ScenarioA: same start and end date — finds games on that day") func scenarioA_sameDayDateRange_findsGamesOnThatDay() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let gameDay = TestClock.addingDays(2) let sameDay = makeGame(id: "sd1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc", dateTime: gameDay) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: gameDay, endDate: gameDay, // Same day numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [sameDay], teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")], stadiums: ["nyc": nycStadium] ) // Should not crash. May find the game or may fail gracefully. let result = ScenarioAPlanner().plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } #expect(!options.isEmpty) } }