// // ScenarioBPlannerTests.swift // SportsTimeTests // // TDD specification tests for ScenarioBPlanner. // import Testing import CoreLocation @testable import SportsTime @Suite("ScenarioBPlanner") struct ScenarioBPlannerTests { // MARK: - Test Data private let planner = ScenarioBPlanner() private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652) // MARK: - Specification Tests: No Selected Games @Test("plan: no selected games returns failure") func plan_noSelectedGames_returnsFailure() { let startDate = Date() let endDate = startDate.addingTimeInterval(86400 * 7) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: [], // No selected games startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when no games selected") return } #expect(failure.reason == .noValidRoutes) } // MARK: - Specification Tests: Anchor Games @Test("plan: single selected game returns success with that game") func plan_singleSelectedGame_returnsSuccess() { let startDate = Date() 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: .gameFirst, sports: [.mlb], mustSeeGameIds: ["game1"], 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 selected game") return } #expect(!options.isEmpty) let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #expect(allGameIds.contains("game1"), "Selected game must be in result") } @Test("plan: all selected games appear in every route") func plan_allSelectedGamesAppearInRoutes() { let startDate = Date() 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: phillyCoord) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400)) 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)) // Select NYC and Boston games as anchors let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["nyc-game", "boston-game"], startDate: startDate, endDate: endDate, leisureLevel: .moderate, 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) if case .success(let options) = result { for option in options { let gameIds = option.stops.flatMap { $0.games } #expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game") #expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game") } } } // MARK: - Regression Tests: Bonus Games in Date Range @Test("plan: gameFirst mode includes bonus games within date range") func plan_gameFirstMode_includesBonusGamesInDateRange() { // Regression test: When a single anchor game is selected, the planner should // find additional "bonus" games within the date range that fit geographically. // Bug: planTrip() was overriding the 7-day date range with just anchor dates, // causing only the anchor game to appear in results. let startDate = Date() let endDate = startDate.addingTimeInterval(86400 * 7) // 7-day span // NYC and Boston are geographically close (drivable) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) // Anchor game on day 4 let anchorGame = makeGame(id: "anchor-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 3)) // Bonus game on day 2 (within date range, geographically sensible) let bonusGame = makeGame(id: "bonus-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 1)) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["anchor-game"], // Only anchor is selected startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [anchorGame, bonusGame], // Both games available teams: [:], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success with bonus game available") return } #expect(!options.isEmpty, "Should have trip options") // At least one option should include the bonus game let optionsWithBonus = options.filter { option in option.stops.flatMap { $0.games }.contains("bonus-game") } #expect(!optionsWithBonus.isEmpty, "At least one route should include bonus game from date range") // ALL options must still contain the anchor game for option in options { let gameIds = option.stops.flatMap { $0.games } #expect(gameIds.contains("anchor-game"), "Anchor game must be in every route") } } @Test("plan: gameFirst mode uses full date range not just anchor dates") func plan_gameFirstMode_usesFullDateRange() { // Regression test: Verify that the planner considers games across the entire // date range, not just on the anchor game dates. let startDate = Date() // 7-day date range let day1 = startDate let day3 = startDate.addingTimeInterval(86400 * 2) let day4 = startDate.addingTimeInterval(86400 * 3) // Anchor game day let day6 = startDate.addingTimeInterval(86400 * 5) let endDate = startDate.addingTimeInterval(86400 * 7) 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) // Anchor game on day 4 let anchorGame = makeGame(id: "anchor", stadiumId: "nyc", dateTime: day4) // Games on other days let day1Game = makeGame(id: "day1-game", stadiumId: "philly", dateTime: day1) let day3Game = makeGame(id: "day3-game", stadiumId: "boston", dateTime: day3) let day6Game = makeGame(id: "day6-game", stadiumId: "philly", dateTime: day6) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["anchor"], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [anchorGame, day1Game, day3Game, day6Game], 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") return } // Collect all game IDs across all options let allGameIdsInOptions = Set(options.flatMap { $0.stops.flatMap { $0.games } }) // At least some non-anchor games should appear in the results // (we don't require ALL because geographic constraints may exclude some) let bonusGamesFound = allGameIdsInOptions.subtracting(["anchor"]) #expect(!bonusGamesFound.isEmpty, "Planner should find bonus games from full date range, not just anchor date") } // MARK: - Specification Tests: Sliding Window @Test("plan: gameFirst mode uses sliding window") func plan_gameFirstMode_usesSlidingWindow() { let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) // Game on a specific date let gameDate = Date().addingTimeInterval(86400 * 5) let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["game1"], startDate: Date(), endDate: Date().addingTimeInterval(86400 * 30), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, gameFirstTripDuration: 7 // 7-day trip ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) // Should succeed even without explicit dates because of sliding window if case .success(let options) = result { #expect(!options.isEmpty) } // May also fail if no valid date ranges, which is acceptable } @Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation") func plan_explicitDateRange_selectedGameOutsideRange_returnsDateRangeViolation() { let baseDate = Date() let rangeStart = baseDate let rangeEnd = baseDate.addingTimeInterval(86400 * 3) let outOfRangeDate = baseDate.addingTimeInterval(86400 * 10) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let selectedGame = makeGame(id: "outside-anchor", stadiumId: "stadium1", dateTime: outOfRangeDate) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], mustSeeGameIds: ["outside-anchor"], startDate: rangeStart, endDate: rangeEnd, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [selectedGame], teams: [:], stadiums: ["stadium1": stadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected date range violation when selected game is outside explicit range") return } guard case .dateRangeViolation(let violatingGames) = failure.reason else { Issue.record("Expected .dateRangeViolation, got \(failure.reason)") return } #expect(Set(violatingGames.map { $0.id }) == ["outside-anchor"]) } // MARK: - Specification Tests: Arrival Time Validation @Test("plan: uses arrivalBeforeGameStart validator") func plan_usesArrivalValidator() { // This test verifies that ScenarioB uses arrival time validation // by creating a scenario where travel time makes arrival impossible let now = Date() let game1Date = now.addingTimeInterval(86400) // Tomorrow let game2Date = now.addingTimeInterval(86400 + 3600) // Tomorrow + 1 hour (impossible to drive from coast to coast) // NYC to LA is ~40 hours of driving let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: game1Date) let laGame = makeGame(id: "la-game", stadiumId: "la", dateTime: game2Date) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["nyc-game", "la-game"], startDate: now, endDate: now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, laGame], teams: [:], stadiums: ["nyc": nycStadium, "la": laStadium] ) let result = planner.plan(request: request) // Should fail because it's impossible to arrive in LA 1 hour after leaving NYC guard case .failure = result else { Issue.record("Expected failure when travel time makes arrival impossible") return } } // MARK: - Invariant Tests @Test("Invariant: selected games cannot be dropped") func invariant_selectedGamesCannotBeDropped() { let startDate = Date() let endDate = startDate.addingTimeInterval(86400 * 14) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let nycGame = makeGame(id: "nyc-anchor", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2)) let bostonGame = makeGame(id: "boston-anchor", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["nyc-anchor", "boston-anchor"], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, bostonGame], teams: [:], stadiums: ["nyc": nycStadium, "boston": bostonStadium] ) let result = planner.plan(request: request) if case .success(let options) = result { for option in options { let gameIds = Set(option.stops.flatMap { $0.games }) #expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor") #expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor") } } } // MARK: - Property Tests @Test("Property: success with selected games includes all anchors") func property_successIncludesAllAnchors() { let startDate = Date() 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: "anchor1", stadiumId: "stadium1", dateTime: gameDate) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["anchor1"], 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) if case .success(let options) = result { #expect(!options.isEmpty, "Success must have options") for option in options { let allGames = option.stops.flatMap { $0.games } #expect(allGames.contains("anchor1"), "Every option must include anchor") } } } // 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 ) } }