// // ScenarioDPlannerTests.swift // SportsTimeTests // // TDD specification tests for ScenarioDPlanner. // import Testing import CoreLocation @testable import SportsTime @Suite("ScenarioDPlanner") struct ScenarioDPlannerTests { // MARK: - Test Data private let planner = ScenarioDPlanner() 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 phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652) // MARK: - Specification Tests: Missing Team @Test("plan: no followTeamId returns missingTeamSelection failure") func plan_noFollowTeamId_returnsMissingTeamSelection() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, followTeamId: nil // Missing ) 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 followTeamId missing") return } #expect(failure.reason == .missingTeamSelection) } // MARK: - Specification Tests: No Team Games @Test("plan: no games for team returns noGamesInRange failure") func plan_noGamesForTeam_returnsNoGamesInRange() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) // Game is for different team let game = Game( id: "game1", homeTeamId: "other-team", awayTeamId: "another-team", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, followTeamId: "yankees" // Team not in any game ) 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 when team has no games") return } #expect(failure.reason == .noGamesInRange) } // MARK: - Specification Tests: Home and Away Games @Test("plan: includes both home and away games for team") func plan_includesBothHomeAndAwayGames() { let startDate = TestClock.now 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) // Home game: Yankees at home let homeGame = Game( id: "home-game", homeTeamId: "yankees", awayTeamId: "red-sox", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) // Away game: Yankees away let awayGame = Game( id: "away-game", homeTeamId: "red-sox", awayTeamId: "yankees", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 5), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, availableGames: [homeGame, awayGame], 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 } // At least one option should include BOTH home and away games let hasOptionWithBoth = options.contains { option in let gameIds = Set(option.stops.flatMap { $0.games }) return gameIds.contains("home-game") && gameIds.contains("away-game") } #expect(hasOptionWithBoth, "At least one option should include both home and away games") } // MARK: - Specification Tests: Region Filtering @Test("plan: with selectedRegions filters team games to those regions") func plan_withSelectedRegions_filtersTeamGames() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 14) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) // East let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) // Central // East coast game let eastGame = Game( id: "east-game", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) // Central game let centralGame = Game( id: "central-game", homeTeamId: "cubs", awayTeamId: "yankees", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400 * 5), sport: .mlb, season: "2026", isPlayoff: false ) var prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, followTeamId: "yankees" ) prefs.selectedRegions = [.east] // Only East coast let request = PlanningRequest( preferences: prefs, availableGames: [eastGame, centralGame], teams: [:], stadiums: ["nyc": nycStadium, "chicago": chicagoStadium] ) 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("east-game"), "East game should be included when East region is selected") #expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region") } // MARK: - Specification Tests: Successful Planning @Test("plan: valid request returns success") func plan_validRequest_returnsSuccess() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) let game = Game( id: "game1", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, followTeamId: "yankees" ) 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 valid follow team request") return } #expect(!options.isEmpty) } @Test("plan: useHomeLocation with startLocation adds home start and end stops") func plan_useHomeLocationWithStartLocation_addsHomeEndpoints() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let homeCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // NYC let homeLocation = LocationInput(name: "New York", coordinate: homeCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let game = Game( id: "team-game", homeTeamId: "red-sox", awayTeamId: "yankees", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, startLocation: homeLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2, followTeamId: "yankees", useHomeLocation: true ) let request = PlanningRequest( preferences: prefs, availableGames: [game], teams: [:], stadiums: ["boston": bostonStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success with home endpoint enabled") return } #expect(!options.isEmpty) for option in options { #expect(option.stops.first?.city == "New York") #expect(option.stops.last?.city == "New York") #expect(option.stops.first?.games.isEmpty == true) #expect(option.stops.last?.games.isEmpty == true) } } // MARK: - Invariant Tests @Test("Invariant: all returned games have team as home or away") func invariant_allGamesHaveTeam() { let startDate = TestClock.now 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 teamId = "yankees" // Games involving the team let homeGame = Game( id: "home", homeTeamId: teamId, awayTeamId: "opponent", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) let awayGame = Game( id: "away", homeTeamId: "red-sox", awayTeamId: teamId, stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 5), sport: .mlb, season: "2026", isPlayoff: false ) // Game NOT involving the team let otherGame = Game( id: "other", homeTeamId: "mets", awayTeamId: "phillies", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 3), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2, followTeamId: teamId ) let request = PlanningRequest( preferences: prefs, availableGames: [homeGame, awayGame, otherGame], 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 } let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded") // Full invariant: ALL returned games must involve the followed team let allGames = [homeGame, awayGame, otherGame] for gameId in allGameIds { let game = allGames.first { $0.id == gameId } #expect(game != nil, "Game ID \(gameId) should be in the available games list") if let game = game { #expect(game.homeTeamId == teamId || game.awayTeamId == teamId, "Game \(gameId) should involve followed team \(teamId)") } } } @Test("Invariant: duplicate routes are removed") func invariant_duplicateRoutesRemoved() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 14) // 3 games for the followed team at nearby cities — the DAG router may // produce multiple routes (e.g. [NYC, BOS], [NYC, PHI], [NYC, BOS, PHI]) // which makes the uniqueness check meaningful. 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 game1 = Game( id: "game-nyc", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) let game2 = Game( id: "game-bos", homeTeamId: "red-sox", awayTeamId: "yankees", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 5), sport: .mlb, season: "2026", isPlayoff: false ) let game3 = Game( id: "game-phi", homeTeamId: "phillies", awayTeamId: "yankees", stadiumId: "philly", dateTime: startDate.addingTimeInterval(86400 * 8), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], 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, got \(result)") return } // Verify no two options have identical game-ID sets var seenGameCombinations = Set() for option in options { let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-") #expect(!seenGameCombinations.contains(gameIds), "Duplicate route found: \(gameIds)") seenGameCombinations.insert(gameIds) } } // 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 = Game( id: "game1", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2), sport: .mlb, season: "2026", isPlayoff: false ) let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1, followTeamId: "yankees" ) 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 stops") } } // 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 ) } }