// // ScenarioAPlannerTests.swift // SportsTimeTests // // TDD specification tests for ScenarioAPlanner. // import Testing import CoreLocation @testable import SportsTime @Suite("ScenarioAPlanner") struct ScenarioAPlannerTests { // MARK: - Test Data private let planner = ScenarioAPlanner() private let calendar = TestClock.calendar // Coordinates for testing 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.8827, longitude: -87.6233) private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673) // MARK: - Specification Tests: No Games @Test("plan: no games in date range returns noGamesInRange failure") func plan_noGamesInRange_returnsFailure() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], // No games teams: [:], stadiums: [:] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure, got success") return } #expect(failure.reason == .noGamesInRange) } @Test("plan: games outside date range returns noGamesInRange") func plan_gamesOutsideDateRange_returnsNoGamesInRange() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) // Game is after the date range let gameDate = endDate.addingTimeInterval(86400 * 30) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure, got success") return } #expect(failure.reason == .noGamesInRange) } // MARK: - Specification Tests: Region Filtering @Test("plan: with selectedRegions filters to those regions") func plan_withSelectedRegions_filtersGames() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let gameDate = startDate.addingTimeInterval(86400 * 2) // NYC stadium (East coast) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) // LA stadium (West coast) let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) let nycGame = makeGame(id: "game-nyc", stadiumId: "nyc", dateTime: gameDate) let laGame = makeGame(id: "game-la", stadiumId: "la", dateTime: gameDate.addingTimeInterval(86400)) var prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) prefs.selectedRegions = [.east] // Only East coast let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, laGame], teams: [:], stadiums: ["nyc": nycStadium, "la": laStadium] ) let result = planner.plan(request: request) // Should succeed with only NYC game (East coast) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } // Verify only East coast games included let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #expect(!allGameIds.contains("game-la"), "LA game should be filtered out by East region filter") #expect(allGameIds.contains("game-nyc"), "NYC game should be included in East region filter") } // MARK: - Specification Tests: Must-Stop Filtering @Test("plan: with mustStopLocation filters to that city") func plan_withMustStopLocation_filtersToCity() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 14) let gameDate = startDate.addingTimeInterval(86400 * 2) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let nycGame = makeGame(id: "game-nyc", stadiumId: "nyc", dateTime: gameDate) let bostonGame = makeGame(id: "game-boston", stadiumId: "boston", dateTime: gameDate.addingTimeInterval(86400)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, mustStopLocations: [LocationInput(name: "New York", coordinate: nycCoord)], lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, bostonGame], teams: [:], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = planner.plan(request: request) // Should include NYC games guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let gameIds = Set(option.stops.flatMap { $0.games }) #expect(gameIds.contains("game-nyc"), "Every option must include NYC game (must-stop constraint)") } } @Test("plan: mustStopLocation with no games in that city returns noGamesInRange") func plan_mustStopNoGamesInCity_returnsNoGamesInRange() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let gameDate = startDate.addingTimeInterval(86400 * 2) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let bostonGame = makeGame(id: "game-boston", stadiumId: "boston", dateTime: gameDate) // Must stop in Chicago, but no games there let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, mustStopLocations: [LocationInput(name: "Chicago", coordinate: chicagoCoord)], lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [bostonGame], teams: [:], stadiums: ["boston": bostonStadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when must-stop city has no games") return } #expect(failure.reason == .noGamesInRange) } @Test("plan: multiple must-stop cities are required without excluding other route games") func plan_multipleMustStops_requireCoverageWithoutExclusiveFiltering() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) 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: CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 1)) let bostonGame = makeGame(id: "boston-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3)) let phillyGame = makeGame(id: "philly-game", stadiumId: "philly", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, mustStopLocations: [ LocationInput(name: "New York", coordinate: nycCoord), LocationInput(name: "Boston", coordinate: bostonCoord) ], lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, bostonGame, phillyGame], teams: [:], stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success with two feasible must-stop cities") return } #expect(!options.isEmpty) for option in options { let gameIds = Set(option.stops.flatMap { $0.games }) #expect(gameIds.contains("nyc-game"), "Each option should satisfy New York must-stop") #expect(gameIds.contains("boston-game"), "Each option should satisfy Boston must-stop") } } // MARK: - Specification Tests: Successful Planning @Test("plan: single game in range returns success with one option") func plan_singleGame_returnsSuccess() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let gameDate = startDate.addingTimeInterval(86400 * 2) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success with single game") return } #expect(!options.isEmpty) #expect(options.first?.stops.first?.games.contains("game1") == true) } @Test("plan: multiple games at same stadium creates single stop") func plan_multipleGamesAtSameStadium_createsSingleStop() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let game1 = makeGame(id: "game1", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400)) let game2 = makeGame(id: "game2", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success") return } // Both games at same stadium should be grouped into one stop if let firstOption = options.first { let nycStops = firstOption.stops.filter { $0.city == "New York" } // Should have 1 stop with 2 games (not 2 stops) let totalGamesInNYC = nycStops.flatMap { $0.games }.count #expect(totalGamesInNYC >= 2, "Both games should be in the route") #expect(nycStops.count == 1, "Two games at same stadium should create exactly one stop, got \(nycStops.count)") } } // MARK: - Invariant Tests @Test("Invariant: returned games are within date range") func invariant_returnedGamesWithinDateRange() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let gameInRange = makeGame(id: "in-range", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2)) let gameOutOfRange = makeGame(id: "out-of-range", stadiumId: "stadium1", dateTime: endDate.addingTimeInterval(86400 * 10)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [gameInRange, gameOutOfRange], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #expect(allGameIds.contains("in-range")) #expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included") } @Test("Invariant: A-B-A creates 3 stops not 2") func invariant_visitSameCityTwice_createsThreeStops() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) // NYC -> Boston -> NYC sequence let game1 = makeGame(id: "nyc1", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400)) let game2 = makeGame(id: "boston1", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3)) let game3 = makeGame(id: "nyc2", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5)) var prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 // More drivers to ensure feasibility ) prefs.allowRepeatCities = true let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], teams: [:], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } // Look for an option that includes all 3 games let optionWithAllGames = options.first { option in let ids = Set(option.stops.flatMap { $0.games }) return ids.contains("nyc1") && ids.contains("boston1") && ids.contains("nyc2") } #expect(optionWithAllGames != nil, "Should have at least one route containing all 3 games") if let option = optionWithAllGames { #expect(option.stops.count >= 3, "NYC-BOS-NYC pattern should create at least 3 stops") } } // MARK: - Property Tests @Test("Property: success always has non-empty options") func property_successHasNonEmptyOptions() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2)) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } #expect(!options.isEmpty, "Success must have at least one option") for option in options { #expect(!option.stops.isEmpty, "Each option must have at least one stop") } } // MARK: - Output Sanity @Test("plan: game with missing stadium excluded, no crash") func plan_missingStadiumForGame_gameExcluded() { let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let validGame = makeGame(id: "valid", stadiumId: "nyc", dateTime: TestClock.addingDays(2)) let orphanGame = Game(id: "orphan", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "no_such_stadium", dateTime: TestClock.addingDays(3), sport: .mlb, season: "2026", isPlayoff: false) 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: [:], stadiums: ["nyc": nycStadium] ) let result = planner.plan(request: request) // Primary assertion: no crash 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: - Helper Methods private func makeStadium( id: String, city: String, coordinate: CLLocationCoordinate2D ) -> Stadium { Stadium( id: id, name: "\(city) Stadium", city: city, state: "XX", latitude: coordinate.latitude, longitude: coordinate.longitude, capacity: 40000, sport: .mlb ) } private func makeGame( id: String, stadiumId: String, dateTime: Date ) -> Game { Game( id: id, homeTeamId: "team1", awayTeamId: "team2", stadiumId: stadiumId, dateTime: dateTime, sport: .mlb, season: "2026", isPlayoff: false ) } }