// // 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) // 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) if case .success(let options) = result { let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } // Both home and away games should be includable let hasHomeGame = allGameIds.contains("home-game") let hasAwayGame = allGameIds.contains("away-game") #expect(hasHomeGame || hasAwayGame, "Should include at least one team game") } } // 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) if case .success(let options) = result { let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #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: 39.7392, longitude: -104.9903) // Denver let homeLocation = LocationInput(name: "Denver", 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 == "Denver") #expect(option.stops.last?.city == "Denver") #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) if case .success(let options) = result { let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } #expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded") } } @Test("Invariant: duplicate routes are removed") func invariant_duplicateRoutesRemoved() { 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) if case .success(let options) = result { // Verify no duplicate game combinations 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) if case .success(let options) = result { #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 ) } }