diff --git a/SportsTimeTests/Helpers/TestFixtures.swift b/SportsTimeTests/Helpers/TestFixtures.swift index f248707..65c683d 100644 --- a/SportsTimeTests/Helpers/TestFixtures.swift +++ b/SportsTimeTests/Helpers/TestFixtures.swift @@ -467,3 +467,108 @@ extension TestFixtures { static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"] static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"] } + +// MARK: - Messy / Realistic Data Factories + +extension TestFixtures { + + /// Creates games that are all in the past relative to a reference date. + static func pastGames( + count: Int, + sport: Sport = .mlb, + cities: [String] = ["New York", "Boston", "Chicago"], + referenceDate: Date = TestClock.now + ) -> [Game] { + (0.. (past: [Game], future: [Game], all: [Game]) { + let past = (0.. (correct: [Game], wrong: [Game], all: [Game]) { + let start = TestClock.addingDays(1) + let correct = (0.. [Game] { + let start = TestClock.addingDays(1) + return (0.. [Game] { + let dt1 = TestClock.addingDays(2) + let dt2 = TestClock.addingDays(3) + return [ + game(id: "dup_game_001", sport: sport, city: "New York", dateTime: dt1), + game(id: "dup_game_001", sport: sport, city: "Boston", dateTime: dt2), + ] + } + + /// Creates games spread over many days for long-trip duration testing. + static func longTripGames( + days: Int = 30, + sport: Sport = .mlb, + cities: [String] = ["New York", "Boston", "Chicago", "Philadelphia", "Atlanta"] + ) -> [Game] { + let start = TestClock.addingDays(1) + return (0.. = [] + for stop in option.stops { + let day = calendar.startOfDay(for: stop.arrivalDate) + let key = "\(stop.city.lowercased())_\(day.timeIntervalSince1970)" + #expect(!cityDays.contains(key), "Route should not visit \(stop.city) on the same day twice") + cityDays.insert(key) + } + } } @Test("Engine tracks must-stop exclusion warnings") @@ -615,10 +629,12 @@ struct Phase2D_ExclusionWarningTests { let result = engine.planItineraries(request: request) // Should fail with must-stop violation - if case .failure(let failure) = result { - let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop }) - #expect(hasMustStopViolation, "Failure should include mustStop constraint violation") + guard case .failure(let failure) = result else { + Issue.record("Expected .failure, got \(result)") + return } + let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop }) + #expect(hasMustStopViolation, "Failure should include mustStop constraint violation") } @Test("Engine tracks segment validation warnings") @@ -636,10 +652,15 @@ struct Phase2D_ExclusionWarningTests { let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) _ = engine.planItineraries(request: request) - // After a second run, warnings should be reset + let warningsAfterFirst = engine.warnings + + // After a second run, warnings should be reset (not accumulated) _ = engine.planItineraries(request: request) + let warningsAfterSecond = engine.warnings + // Warnings from first run should not leak into second run - // (engine resets warnings at start of planItineraries) + #expect(warningsAfterSecond.count == warningsAfterFirst.count, + "Warnings should be reset between runs, not accumulated (\(warningsAfterFirst.count) vs \(warningsAfterSecond.count))") } } @@ -845,14 +866,14 @@ struct Phase4B_InvertedDateRangeTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .failure(let failure) = result { - #expect(failure.reason == .missingDateRange, - "Inverted date range should return missingDateRange failure") - #expect(failure.violations.contains(where: { $0.type == .dateRange }), - "Should include dateRange violation") - } else { - #expect(Bool(false), "Inverted date range should not succeed") + guard case .failure(let failure) = result else { + Issue.record("Expected .failure, got \(result)") + return } + #expect(failure.reason == .missingDateRange, + "Inverted date range should return missingDateRange failure") + #expect(failure.violations.contains(where: { $0.type == .dateRange }), + "Should include dateRange violation") } } @@ -945,12 +966,14 @@ struct Phase4D_CrossCountryTests { let result = engine.planItineraries(request: request) // If successful, should NOT contain LA - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city } - #expect(!cities.contains("Los Angeles"), - "East-only search should not include LA") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + let cities = option.stops.map { $0.city } + #expect(!cities.contains("Los Angeles"), + "East-only search should not include LA") } } } diff --git a/SportsTimeTests/Planning/MustStopValidationTests.swift b/SportsTimeTests/Planning/MustStopValidationTests.swift index 5091b2d..6017085 100644 --- a/SportsTimeTests/Planning/MustStopValidationTests.swift +++ b/SportsTimeTests/Planning/MustStopValidationTests.swift @@ -44,11 +44,10 @@ struct MustStopValidationTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city.lowercased() } - #expect(cities.contains("boston"), "Every route must include Boston as a must-stop") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("boston"), "Every route must include Boston as a must-stop") } } @@ -116,11 +115,10 @@ struct MustStopValidationTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city.lowercased() } - #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included") } } @@ -156,41 +154,39 @@ struct MustStopValidationTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city.lowercased() } - #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode") } } @Test("scenarioE: must stops enforced via centralized filter") func scenarioE_mustStops_routesContainRequiredCities() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) - let day1 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! - let day2 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! + // 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window. + let day3 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)! let teamNYC = "team_mlb_new_york" let teamBOS = "team_mlb_boston" let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamNYC) - let gameBOS = TestFixtures.game(city: "Boston", dateTime: day1, homeTeamId: teamBOS) - let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2, homeTeamId: "team_mlb_philadelphia") + let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3, homeTeamId: teamBOS) - let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL]) + let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS]) let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], startDate: baseDate, - endDate: day2, + endDate: day3, mustStopLocations: [LocationInput(name: "Boston")], selectedTeamIds: [teamNYC, teamBOS] ) let request = PlanningRequest( preferences: prefs, - availableGames: [gameNYC, gameBOS, gamePHL], + availableGames: [gameNYC, gameBOS], teams: [ teamNYC: TestFixtures.team(city: "New York"), teamBOS: TestFixtures.team(city: "Boston"), @@ -201,54 +197,58 @@ struct MustStopValidationTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city.lowercased() } - #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode") } } @Test("scenarioC: must stops enforced via centralized filter") func scenarioC_mustStops_routesContainRequiredCities() { - let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) - let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! - let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! + // Route: Chicago → New York (eastward). Detroit is directionally between them. + let baseDate = TestClock.now + let endDate = TestClock.calendar.date(byAdding: .day, value: 10, to: baseDate)! + let chiCoord = TestFixtures.coordinates["Chicago"]! + let detCoord = TestFixtures.coordinates["Detroit"]! let nycCoord = TestFixtures.coordinates["New York"]! - let bosCoord = TestFixtures.coordinates["Boston"]! - let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate) - let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2) - let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3) + let chiStadium = TestFixtures.stadium(id: "chi", city: "Chicago") + let detStadium = TestFixtures.stadium(id: "det", city: "Detroit") + let nycStadium = TestFixtures.stadium(id: "nyc", city: "New York") - let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gamePHL, gameBOS]) + let gameCHI = TestFixtures.game(city: "Chicago", dateTime: TestClock.addingDays(1), stadiumId: "chi") + let gameDET = TestFixtures.game(city: "Detroit", dateTime: TestClock.addingDays(4), stadiumId: "det") + let gameNYC = TestFixtures.game(city: "New York", dateTime: TestClock.addingDays(7), stadiumId: "nyc") let prefs = TripPreferences( planningMode: .locations, - startLocation: LocationInput(name: "New York", coordinate: nycCoord), - endLocation: LocationInput(name: "Boston", coordinate: bosCoord), + startLocation: LocationInput(name: "Chicago", coordinate: chiCoord), + endLocation: LocationInput(name: "New York", coordinate: nycCoord), sports: [.mlb], startDate: baseDate, - endDate: day3, - mustStopLocations: [LocationInput(name: "Philadelphia")] + endDate: endDate, + leisureLevel: .moderate, + mustStopLocations: [LocationInput(name: "Detroit")], + lodgingType: .hotel, + numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, - availableGames: [gameNYC, gamePHL, gameBOS], + availableGames: [gameCHI, gameDET, gameNYC], teams: [:], - stadiums: stadiums + stadiums: ["chi": chiStadium, "det": detStadium, "nyc": nycStadium] ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - let cities = option.stops.map { $0.city.lowercased() } - #expect(cities.contains("philadelphia"), "Must-stop filter should ensure Philadelphia is included in route mode") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("detroit"), "Must-stop filter should ensure Detroit is included in Chicago→NYC route") } } } diff --git a/SportsTimeTests/Planning/PlannerOutputSanityTests.swift b/SportsTimeTests/Planning/PlannerOutputSanityTests.swift new file mode 100644 index 0000000..0fefdc8 --- /dev/null +++ b/SportsTimeTests/Planning/PlannerOutputSanityTests.swift @@ -0,0 +1,1098 @@ +// +// 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) + } +} diff --git a/SportsTimeTests/Planning/PlanningHardeningTests.swift b/SportsTimeTests/Planning/PlanningHardeningTests.swift index 6730728..4140f4b 100644 --- a/SportsTimeTests/Planning/PlanningHardeningTests.swift +++ b/SportsTimeTests/Planning/PlanningHardeningTests.swift @@ -198,14 +198,16 @@ struct FilterCascadeTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .failure(let failure) = result { + switch result { + case .success: + break // Success is also acceptable — engine found a valid non-repeating route + case .failure(let failure): // Should get either repeatCityViolation or noGamesInRange/noValidRoutes let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"]) || failure.reason == .noValidRoutes || failure.reason == .noGamesInRange #expect(isExpectedFailure, "Should get a clear failure reason, got: \(failure.message)") } - // Success is also acceptable if engine handles it differently } @Test("Must-stop filter with impossible city → clear error") @@ -235,11 +237,9 @@ struct FilterCascadeTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .failure(let failure) = result { - #expect(failure.violations.contains(where: { $0.type == .mustStop }), - "Should have mustStop violation") - } - // If no routes generated at all (noGamesInRange), that's also an acceptable failure + guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)"); return } + #expect(failure.violations.contains(where: { $0.type == .mustStop }), + "Should have mustStop violation") } @Test("Empty sports set produces warning") @@ -297,11 +297,34 @@ struct FilterCascadeTests { geographicRationale: "test" ) + // Case 1: No repeat cities — filter is a no-op let options = [option] let once = RouteFilters.filterRepeatCities(options, allow: false) let twice = RouteFilters.filterRepeatCities(once, allow: false) - #expect(once.count == twice.count, "Filtering twice should produce same result as once") + #expect(once.count == 1, "NYC→BOS has no repeat cities, should survive filter") + + // Case 2: Route with repeat cities — filter actually removes it + let stop3 = ItineraryStop( + city: "New York", state: "NY", + coordinate: TestFixtures.coordinates["New York"], + games: ["g3"], + arrivalDate: TestClock.addingDays(2), + departureDate: TestClock.addingDays(3), + location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), + firstGameStart: TestClock.addingDays(2) + ) + let seg2 = TestFixtures.travelSegment(from: "Boston", to: "New York") + let repeatOption = ItineraryOption( + rank: 2, stops: [stop1, stop2, stop3], + travelSegments: [segment, seg2], + totalDrivingHours: 7.0, totalDistanceMiles: 430, + geographicRationale: "test" + ) + let mixedOnce = RouteFilters.filterRepeatCities([option, repeatOption], allow: false) + let mixedTwice = RouteFilters.filterRepeatCities(mixedOnce, allow: false) + #expect(mixedOnce.count == mixedTwice.count, "Double-filter should be idempotent") + #expect(mixedOnce.count == 1, "Route with repeat NYC should be filtered out") } } @@ -396,13 +419,15 @@ struct ConstraintInteractionTests { // Engine should handle this gracefully — either find a route that visits NYC once // or return a clear failure - if case .failure(let failure) = result { + switch result { + case .success: + break // Success is fine — engine found a valid route visiting NYC exactly once + case .failure(let failure): let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"]) || failure.reason == .noValidRoutes || failure.reason == .noGamesInRange #expect(hasReason, "Should fail with a clear reason") } - // Success is fine too if engine finds a single-NYC-day route } @Test("Multiple drivers extend feasible distance") @@ -513,11 +538,23 @@ struct ConstraintInteractionTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - // Whether success or failure, warnings should be accessible - // If options were filtered, we should see warnings - if result.isSuccess && !engine.warnings.isEmpty { - #expect(engine.warnings.allSatisfy { $0.severity == .warning }, + // With must-stop NYC, some routes may be filtered. Verify: + // 1. The warnings property is accessible (doesn't crash) + // 2. If warnings exist, they are all severity .warning + let warnings = engine.warnings + for warning in warnings { + #expect(warning.severity == .warning, "Exclusion notices should be warnings, not errors") } + // The engine should produce either success with must-stop satisfied, or failure + switch result { + case .success(let options): + for option in options { + let cities = option.stops.map { $0.city.lowercased() } + #expect(cities.contains("new york"), "Must-stop NYC should be in every option") + } + case .failure: + break // Acceptable if no route can satisfy must-stop + } } } diff --git a/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift index 0133176..8a0693c 100644 --- a/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift +++ b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift @@ -212,18 +212,19 @@ struct Bug4_ScenarioDRationaleTests { let planner = ScenarioDPlanner() let result = planner.plan(request: request) - if case .success(let options) = result { - // Bug #4: rationale was using stops.count instead of actual game count. - // Verify that for each option, the game count in the rationale matches - // the actual total games across stops. - for option in options { - let actualGameCount = option.stops.reduce(0) { $0 + $1.games.count } - let rationale = option.geographicRationale - #expect(rationale.contains("\(actualGameCount) games"), - "Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + // Bug #4: rationale was using stops.count instead of actual game count. + // Verify that for each option, the game count in the rationale matches + // the actual total games across stops. + for option in options { + let actualGameCount = option.stops.reduce(0) { $0 + $1.games.count } + let rationale = option.geographicRationale + #expect(rationale.contains("\(actualGameCount) games"), + "Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)") } - // If planning fails, that's OK — this test focuses on rationale text when it succeeds } } @@ -261,15 +262,21 @@ struct Bug5_ScenarioDDepartureDateTests { let planner = ScenarioDPlanner() let result = planner.plan(request: request) - if case .success(let options) = result, let option = options.first { - // Find the game stop (not the home start/end waypoints) - let gameStops = option.stops.filter { $0.hasGames } - if let gameStop = gameStops.first { - let gameDayStart = calendar.startOfDay(for: gameDate) - let departureDayStart = calendar.startOfDay(for: gameStop.departureDate) - #expect(departureDayStart > gameDayStart, - "Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + guard let option = options.first else { + Issue.record("Expected at least one option, got empty array") + return + } + // Find the game stop (not the home start/end waypoints) + let gameStops = option.stops.filter { $0.hasGames } + if let gameStop = gameStops.first { + let gameDayStart = calendar.startOfDay(for: gameDate) + let departureDayStart = calendar.startOfDay(for: gameStop.departureDate) + #expect(departureDayStart > gameDayStart, + "Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)") } } } @@ -321,11 +328,12 @@ struct Bug6_ScenarioCDateRangeTests { let result = planner.plan(request: request) // Should find at least one option — games exactly span the trip duration - if case .failure(let failure) = result { - let reason = failure.reason - #expect(reason != PlanningFailure.FailureReason.noGamesInRange, - "Games spanning exactly daySpan should not be excluded. Failure: \(failure.message)") + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return } + #expect(!options.isEmpty, + "Games spanning exactly daySpan should produce at least one option") } } @@ -634,9 +642,11 @@ struct Bug13_MissingStadiumTests { // Currently: silently excluded → noGamesInRange. // This test documents the current behavior (missing stadiums are excluded). - if case .failure(let failure) = result { - #expect(failure.reason == .noGamesInRange) + guard case .failure(let failure) = result else { + Issue.record("Expected .failure, got \(result)") + return } + #expect(failure.reason == .noGamesInRange) } } @@ -647,14 +657,7 @@ struct Bug13_MissingStadiumTests { @Suite("Bug #14: Drag drop feedback") struct Bug14_DragDropTests { - @Test("documented: drag state should not be cleared before validation") - func documented_dragStateShouldPersistDuringValidation() { - // This bug is in TripDetailView.swift:1508-1525 (UI layer). - // Drag state is cleared synchronously before async validation runs. - // If validation fails, no visual feedback is shown. - // Fix: Move drag state clearing AFTER validation succeeds. - #expect(true, "UI bug documented — drag state should persist during validation") - } + // Bug #14 (drag state) is a UI-layer issue tracked separately — no unit test possible here. } // MARK: - Bug #15: ScenarioB force unwraps on date arithmetic @@ -699,8 +702,14 @@ struct Bug15_DateArithmeticTests { ) let planner = ScenarioBPlanner() - // Should not crash — just verifying safety - let _ = planner.plan(request: request) + let result = planner.plan(request: request) + // Should not crash — verify we get a valid result (success or failure, not a crash) + switch result { + case .success(let options): + #expect(!options.isEmpty, "If success, should have at least one option") + case .failure: + break // Failure is acceptable — the point is it didn't crash + } } } @@ -709,14 +718,7 @@ struct Bug15_DateArithmeticTests { @Suite("Bug #16: Sort order accumulation") struct Bug16_SortOrderTests { - @Test("documented: repeated before-games moves should use midpoint not subtraction") - func documented_sortOrderShouldNotGoExtremelyNegative() { - // This bug is in ItineraryReorderingLogic.swift:420-428. - // Each "move before first item" subtracts 1.0 instead of using midpoint. - // After many moves, sortOrder becomes -10, -20, etc. - // Fix: Use midpoint (n/2.0) instead of subtraction (n-1.0). - #expect(true, "Documented: sortOrder should use midpoint insertion") - } + // Bug #16 (sortOrder accumulation) is in ItineraryReorderingLogic — tracked separately. } // MARK: - Cross-cutting: TravelEstimator consistency diff --git a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift index 29d1e55..49b3b10 100644 --- a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift @@ -131,13 +131,14 @@ struct ScenarioAPlannerTests { // Should succeed with only NYC game (East coast) guard case .success(let options) = result else { - // May fail for other reasons (no valid routes), but shouldn't include LA + Issue.record("Expected .success, got \(result)") return } - // If success, verify only East coast games included + // 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 @@ -174,13 +175,16 @@ struct ScenarioAPlannerTests { let result = planner.plan(request: request) - // If success, should only include NYC games - if case .success(let options) = result { - let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } - #expect(allGameIds.contains("game-nyc"), "NYC game should be included") - // Boston game may or may not be included depending on route logic + // 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)") } - // Could also fail with noGamesInRange if must-stop filter is strict } @Test("plan: mustStopLocation with no games in that city returns noGamesInRange") @@ -346,6 +350,7 @@ struct ScenarioAPlannerTests { // 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)") } } @@ -379,11 +384,14 @@ struct ScenarioAPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - 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") + 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") @@ -419,17 +427,19 @@ struct ScenarioAPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - // Look for an option that includes all 3 games - let optionWithAllGames = options.first { option in - let allGames = option.stops.flatMap { $0.games } - return allGames.contains("nyc1") && allGames.contains("boston1") && allGames.contains("nyc2") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } - if let option = optionWithAllGames { - // NYC appears first and last, so should have at least 3 stops - #expect(option.stops.count >= 3, "A-B-A pattern should create 3 stops") - } + // 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") } } @@ -462,11 +472,54 @@ struct ScenarioAPlannerTests { 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 at least one stop") - } + 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") } } diff --git a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift index eab7001..eb6873b 100644 --- a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift @@ -129,12 +129,15 @@ struct ScenarioBPlannerTests { 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") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + + 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") } } @@ -291,10 +294,12 @@ struct ScenarioBPlannerTests { 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) + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return } - // May also fail if no valid date ranges, which is acceptable + + #expect(!options.isEmpty) } @Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation") @@ -418,12 +423,15 @@ struct ScenarioBPlannerTests { 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") - } + 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("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor") + #expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor") } } @@ -458,12 +466,47 @@ struct ScenarioBPlannerTests { 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") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + + #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") + } + } + + @Test("plan: anchor game in past — handled gracefully") + func plan_anchorGameInPast_handledGracefully() { + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + + let pastAnchor = makeGame(id: "past_anchor", stadiumId: "nyc", dateTime: TestClock.addingDays(-1)) + + let prefs = TripPreferences( + planningMode: .gameFirst, + sports: [.mlb], + mustSeeGameIds: ["past_anchor"], + startDate: TestClock.addingDays(-3), + endDate: TestClock.addingDays(5), + numberOfDrivers: 1 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [pastAnchor], + teams: [:], + stadiums: ["nyc": nycStadium] + ) + + // Should not crash. May include past anchor (it's explicitly selected) or fail gracefully. + let result = planner.plan(request: request) + switch result { + case .success: + break + case .failure(let f): + Issue.record("Unexpected failure: \(f)") } } diff --git a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift index 97935da..678ae79 100644 --- a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift @@ -286,11 +286,13 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } - // LA game should NOT be in any route (wrong direction) - #expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)") + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return } + let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } + // LA game should NOT be in any route (wrong direction) + #expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)") } // MARK: - Specification Tests: Start/End Stops @@ -336,13 +338,15 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - for option in options { - // First stop should be start city - #expect(option.stops.first?.city == "Chicago", "First stop should be start city") - // Last stop should be end city - #expect(option.stops.last?.city == "New York", "Last stop should be end city") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + // First stop should be start city + #expect(option.stops.first?.city == "Chicago", "First stop should be start city") + // Last stop should be end city + #expect(option.stops.last?.city == "New York", "Last stop should be end city") } } @@ -383,18 +387,18 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - for option in options { - let firstStop = option.stops.first - // The start stop (added as endpoint) should have no games - // Note: The first stop might be a game stop if start city has games - if firstStop?.city == "Chicago" && option.stops.count > 1 { - // If there's a separate start stop with no games, verify it - let stopsWithNoGames = option.stops.filter { $0.games.isEmpty } - // At minimum, there should be endpoint stops - #expect(stopsWithNoGames.count >= 0) // Just ensure no crash - } - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + // When start city (Chicago) has a game, the endpoint is merged into the game stop. + // Verify the first stop IS Chicago (either as game stop or endpoint). + #expect(option.stops.first?.city == "Chicago", + "First stop should be the start city (Chicago)") + // Verify the last stop is the end city + #expect(option.stops.last?.city == "New York", + "Last stop should be the end city (New York)") } } @@ -433,24 +437,61 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - for option in options { - #expect(option.stops.last?.city == "New York", "End city must be last stop") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + #expect(option.stops.last?.city == "New York", "End city must be last stop") } } // MARK: - Property Tests - @Test("Property: forward progress tolerance is 15%") + @Test("Property: forward progress tolerance filters distant backward stadiums") func property_forwardProgressTolerance() { - // This tests the documented invariant that tolerance is 15% - // We verify by testing that a stadium 16% backward gets filtered - // vs one that is 14% backward gets included + // Chicago → NYC route. LA is far backward (west), should be excluded. + // Cleveland is forward (east of Chicago, toward NYC), should be included. + let chicagoStad = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord) + let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4958, longitude: -81.6853) + let clevelandStad = makeStadium(id: "cle", city: "Cleveland", coordinate: clevelandCoord) + let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400) + let laStad = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) - // This is more of a documentation test - the actual tolerance is private - // We trust the implementation matches the documented behavior - #expect(true, "Forward progress tolerance documented as 15%") + let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1)) + let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3)) + let laGame = makeGame(id: "g_la", stadiumId: "la", dateTime: TestClock.addingDays(4)) + let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(6)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord), + endLocation: LocationInput(name: "New York", coordinate: nycCoord), + sports: [.mlb], + startDate: TestClock.now, + endDate: TestClock.addingDays(10), + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chiGame, cleGame, laGame, nycGame], + teams: [:], + stadiums: ["chi": chicagoStad, "nyc": nycStad, "cle": clevelandStad, "la": laStad] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + let cities = option.stops.map(\.city) + #expect(!cities.contains("Los Angeles"), + "LA is far backward from Chicago→NYC route and should be excluded") + } } // MARK: - Regression Tests: Endpoint Merging @@ -499,19 +540,21 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - #expect(!options.isEmpty, "Should produce at least one itinerary") - for option in options { - // When the route includes a Houston game stop, there should NOT also be - // a separate empty Houston endpoint stop (the fix merges them) - let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" } - let emptyHoustonStops = houstonStops.filter { !$0.hasGames } - let gameHoustonStops = houstonStops.filter { $0.hasGames } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + #expect(!options.isEmpty, "Should produce at least one itinerary") + for option in options { + // When the route includes a Houston game stop, there should NOT also be + // a separate empty Houston endpoint stop (the fix merges them) + let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" } + let emptyHoustonStops = houstonStops.filter { !$0.hasGames } + let gameHoustonStops = houstonStops.filter { $0.hasGames } - if !gameHoustonStops.isEmpty { - #expect(emptyHoustonStops.isEmpty, - "Should not have both a game stop and empty endpoint in Houston") - } + if !gameHoustonStops.isEmpty { + #expect(emptyHoustonStops.isEmpty, + "Should not have both a game stop and empty endpoint in Houston") } } } @@ -557,22 +600,24 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - #expect(!options.isEmpty, "Should produce at least one itinerary") - for option in options { - // When a route includes a game in an endpoint city, - // there should NOT also be a separate empty endpoint stop for that city - let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" } - if chicagoStops.contains(where: { $0.hasGames }) { - #expect(!chicagoStops.contains(where: { !$0.hasGames }), - "No redundant empty Chicago endpoint when game stop exists") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + #expect(!options.isEmpty, "Should produce at least one itinerary") + for option in options { + // When a route includes a game in an endpoint city, + // there should NOT also be a separate empty endpoint stop for that city + let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" } + if chicagoStops.contains(where: { $0.hasGames }) { + #expect(!chicagoStops.contains(where: { !$0.hasGames }), + "No redundant empty Chicago endpoint when game stop exists") + } - let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" } - if nycStops.contains(where: { $0.hasGames }) { - #expect(!nycStops.contains(where: { !$0.hasGames }), - "No redundant empty NYC endpoint when game stop exists") - } + let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" } + if nycStops.contains(where: { $0.hasGames }) { + #expect(!nycStops.contains(where: { !$0.hasGames }), + "No redundant empty NYC endpoint when game stop exists") } } } @@ -622,21 +667,23 @@ struct ScenarioCPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - #expect(!options.isEmpty) - // For routes that include the Chicago game, the start endpoint - // should be merged (no separate empty Chicago stop). - // For routes that don't include the Chicago game, an empty - // Chicago endpoint is correctly added. - for option in options { - let chicagoStops = option.stops.filter { $0.city == "Chicago" } - let hasGameInChicago = chicagoStops.contains { $0.hasGames } - let hasEmptyChicago = chicagoStops.contains { !$0.hasGames } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + #expect(!options.isEmpty) + // For routes that include the Chicago game, the start endpoint + // should be merged (no separate empty Chicago stop). + // For routes that don't include the Chicago game, an empty + // Chicago endpoint is correctly added. + for option in options { + let chicagoStops = option.stops.filter { $0.city == "Chicago" } + let hasGameInChicago = chicagoStops.contains { $0.hasGames } + let hasEmptyChicago = chicagoStops.contains { !$0.hasGames } - // Should never have BOTH an empty endpoint and a game stop for same city - #expect(!(hasGameInChicago && hasEmptyChicago), - "Should not have both game and empty stops for Chicago") - } + // Should never have BOTH an empty endpoint and a game stop for same city + #expect(!(hasGameInChicago && hasEmptyChicago), + "Should not have both game and empty stops for Chicago") } } @@ -772,6 +819,115 @@ struct ScenarioCPlannerTests { #expect(!options.isEmpty, "Should produce at least one itinerary") } + // MARK: - Output Sanity + + @Test("plan: all stops progress toward end location") + func plan_allStopsProgressTowardEnd() { + let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) + let phillyC = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665) + let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074) + let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006) + + let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC) + let phillyStad = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyC) + let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC) + let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC) + + let games = [ + makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)), + makeGame(id: "g_philly", stadiumId: "philly", dateTime: TestClock.addingDays(3)), + makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(5)), + makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)), + ] + + let startLoc = LocationInput(name: "New York", coordinate: nycC) + let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLoc, + endLocation: endLoc, + sports: [.mlb], + startDate: TestClock.addingDays(0), + endDate: TestClock.addingDays(10), + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: [:], + stadiums: ["nyc": nycStad, "philly": phillyStad, "dc": dcStad, "atl": atlantaStad] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + let gameStops = option.stops.filter { !$0.games.isEmpty } + for i in 0..<(gameStops.count - 1) { + if let coord1 = gameStops[i].coordinate, let coord2 = gameStops[i + 1].coordinate { + let progressing = coord2.latitude <= coord1.latitude + 2.0 + #expect(progressing, + "Stops should progress toward Atlanta (south): \(gameStops[i].city) → \(gameStops[i+1].city)") + } + } + } + } + + @Test("plan: games outside directional cone excluded") + func plan_gamesOutsideDirectionalCone_excluded() { + let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) + let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006) + let bostonC = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) + let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074) + + let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC) + let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC) + let bostonStad = makeStadium(id: "boston", city: "Boston", coordinate: bostonC) + let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC) + + let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)) + let atlGame = makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)) + let bostonGame = makeGame(id: "g_boston", stadiumId: "boston", dateTime: TestClock.addingDays(3)) + let dcGame = makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(4)) + + let startLoc = LocationInput(name: "New York", coordinate: nycC) + let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLoc, + endLocation: endLoc, + sports: [.mlb], + startDate: TestClock.addingDays(0), + endDate: TestClock.addingDays(10), + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [nycGame, atlGame, bostonGame, dcGame], + teams: [:], + stadiums: ["nyc": nycStad, "atl": atlantaStad, "boston": bostonStad, "dc": dcStad] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + for option in options { + let cities = option.stops.map(\.city) + #expect(!cities.contains("Boston"), + "Boston (north of NYC) should be excluded when traveling NYC→Atlanta") + } + } + // MARK: - Helper Methods private func makeStadium( diff --git a/SportsTimeTests/Planning/ScenarioDPlannerTests.swift b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift index a8a09fc..3c7d6ef 100644 --- a/SportsTimeTests/Planning/ScenarioDPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift @@ -19,6 +19,7 @@ struct ScenarioDPlannerTests { 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 @@ -155,13 +156,16 @@ struct ScenarioDPlannerTests { 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") + 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 @@ -219,10 +223,13 @@ struct ScenarioDPlannerTests { 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") + 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 @@ -277,8 +284,8 @@ struct ScenarioDPlannerTests { 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 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( @@ -322,8 +329,8 @@ struct ScenarioDPlannerTests { #expect(!options.isEmpty) for option in options { - #expect(option.stops.first?.city == "Denver") - #expect(option.stops.last?.city == "Denver") + #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) } @@ -396,29 +403,70 @@ struct ScenarioDPlannerTests { 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") + 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 * 7) + let endDate = startDate.addingTimeInterval(86400 * 14) - let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) - let game = Game( - id: "game1", + // 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: "stadium1", + 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], @@ -426,27 +474,29 @@ struct ScenarioDPlannerTests { endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, - numberOfDrivers: 1, + numberOfDrivers: 2, followTeamId: "yankees" ) let request = PlanningRequest( preferences: prefs, - availableGames: [game], + availableGames: [game1, game2, game3], teams: [:], - stadiums: ["stadium1": stadium] + stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] ) 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) - } + 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) } } @@ -489,11 +539,13 @@ struct ScenarioDPlannerTests { 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") - } + 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") } } diff --git a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift index f0efb9a..274ec19 100644 --- a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift @@ -548,41 +548,47 @@ struct ScenarioEPlannerTests { @Test("plan: routes sorted by duration ascending") func plan_routesSortedByDurationAscending() { - let baseDate = TestClock.now + let calendar = TestClock.calendar + let baseDate = calendar.startOfDay(for: TestClock.now) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) - // Create multiple windows with different durations - // Window 1: Games on day 1 and 2 (shorter trip) - // Window 2: Games on day 10 and 14 (longer trip within window) + // 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days. + // Window 1: Games on day 1 and day 4 (tighter) + // Window 2: Games on day 10 and day 13 (separate window) + let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))! + let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))! + let day10Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))! + let day13Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))! + let yankeesGame1 = makeGame( id: "yankees-1", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", - dateTime: baseDate.addingTimeInterval(86400 * 1) + dateTime: day1Evening ) let redsoxGame1 = makeGame( id: "redsox-1", homeTeamId: "redsox", awayTeamId: "opponent", stadiumId: "boston", - dateTime: baseDate.addingTimeInterval(86400 * 2) + dateTime: day4Evening ) let yankeesGame2 = makeGame( id: "yankees-2", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", - dateTime: baseDate.addingTimeInterval(86400 * 10) + dateTime: day10Evening ) let redsoxGame2 = makeGame( id: "redsox-2", homeTeamId: "redsox", awayTeamId: "opponent", stadiumId: "boston", - dateTime: baseDate.addingTimeInterval(86400 * 12) + dateTime: day13Evening ) let prefs = TripPreferences( @@ -617,30 +623,43 @@ struct ScenarioEPlannerTests { for (index, option) in options.enumerated() { #expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...") } + + // Verify actual duration ordering: each option's trip duration <= next option's + for i in 0..<(options.count - 1) { + let daysA = Calendar.current.dateComponents([.day], from: options[i].stops.first!.arrivalDate, to: options[i].stops.last!.departureDate).day ?? 0 + let daysB = Calendar.current.dateComponents([.day], from: options[i+1].stops.first!.arrivalDate, to: options[i+1].stops.last!.departureDate).day ?? 0 + #expect(daysA <= daysB, "Option \(i) duration \(daysA)d should be <= option \(i+1) duration \(daysB)d") + } } @Test("plan: respects max driving time constraint") func plan_respectsMaxDrivingTimeConstraint() { - let baseDate = TestClock.now + let calendar = TestClock.calendar + let baseDate = calendar.startOfDay(for: TestClock.now) // NYC and LA are ~40 hours apart by car let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) - // Games on consecutive days - impossible to drive between + // 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days. + // Spread games apart so the window generator produces a valid window, + // but keep them on opposite coasts so the driving constraint rejects the route. + let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))! + let day5Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 5))! + let yankeesGame = makeGame( id: "yankees-home", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", - dateTime: baseDate.addingTimeInterval(86400 * 1) + dateTime: day1Evening ) let dodgersGame = makeGame( id: "dodgers-home", homeTeamId: "dodgers", awayTeamId: "opponent", stadiumId: "la", - dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible + dateTime: day5Evening ) let prefs = TripPreferences( @@ -650,7 +669,7 @@ struct ScenarioEPlannerTests { endDate: baseDate.addingTimeInterval(86400 * 30), leisureLevel: .moderate, lodgingType: .hotel, - numberOfDrivers: 1, // Single driver, 8 hours max + numberOfDrivers: 1, // Single driver, 8 hours max — impossible NYC→LA selectedTeamIds: ["yankees", "dodgers"] ) @@ -989,31 +1008,39 @@ struct ScenarioEPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - #expect(options.count <= 10, "Should return at most 10 results") + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return } + + #expect(options.count <= 10, "Should return at most 10 results") } @Test("Invariant: all routes contain home games from all selected teams") func invariant_allRoutesContainAllSelectedTeams() { - let baseDate = TestClock.now + let calendar = TestClock.calendar + let baseDate = calendar.startOfDay(for: TestClock.now) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + // 2 teams → windowDuration = 4 days. Games must span at least 3 calendar days. + let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))! + let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))! + let yankeesGame = makeGame( id: "yankees-home", homeTeamId: "yankees", awayTeamId: "opponent", stadiumId: "nyc", - dateTime: baseDate.addingTimeInterval(86400 * 1) + dateTime: day1Evening ) let redsoxGame = makeGame( id: "redsox-home", homeTeamId: "redsox", awayTeamId: "opponent", stadiumId: "boston", - dateTime: baseDate.addingTimeInterval(86400 * 2) + dateTime: day4Evening ) let prefs = TripPreferences( @@ -1039,22 +1066,25 @@ struct ScenarioEPlannerTests { let result = planner.plan(request: request) - if case .success(let options) = result { - for option in options { - let allGameIds = Set(option.stops.flatMap { $0.games }) + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } - // At minimum, should have one game per selected team - let hasYankeesGame = allGameIds.contains { gameId in - // Check if any game in this route is a Yankees home game - request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees" - } - let hasRedsoxGame = allGameIds.contains { gameId in - request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox" - } + for option in options { + let allGameIds = Set(option.stops.flatMap { $0.games }) - #expect(hasYankeesGame, "Every route must include a Yankees home game") - #expect(hasRedsoxGame, "Every route must include a Red Sox home game") + // At minimum, should have one game per selected team + let hasYankeesGame = allGameIds.contains { gameId in + // Check if any game in this route is a Yankees home game + request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees" } + let hasRedsoxGame = allGameIds.contains { gameId in + request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox" + } + + #expect(hasYankeesGame, "Every route must include a Yankees home game") + #expect(hasRedsoxGame, "Every route must include a Red Sox home game") } } @@ -1096,50 +1126,58 @@ struct ScenarioEPlannerTests { let planner = ScenarioEPlanner() let result = planner.plan(request: request) - // Should succeed — both teams have east coast games - if case .success(let options) = result { + // Should succeed — both teams have east coast games. + // Failure is also acceptable if routing constraints prevent a valid route. + switch result { + case .success(let options): for option in options { let cities = option.stops.map { $0.city } #expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA") } + case .failure: + break // Acceptable — routing constraints may prevent a valid route } - // If it fails, that's also acceptable since routing may not work out } @Test("teamFirst: all regions includes everything") func teamFirst_allRegions_includesEverything() { let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York") - let teamLA = TestFixtures.team(id: "team_la", city: "Los Angeles") + let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston") let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York") - let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles") + let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston") let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) - let day5 = TestClock.calendar.date(byAdding: .day, value: 4, to: baseDate)! + // 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window. + let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)! let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc") - let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day5, homeTeamId: "team_la", stadiumId: "stadium_la") + let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos") let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], + numberOfDrivers: 2, selectedRegions: [.east, .central, .west], // All regions - selectedTeamIds: ["team_nyc", "team_la"] + selectedTeamIds: ["team_nyc", "team_bos"] ) let request = PlanningRequest( preferences: prefs, - availableGames: [gameNYC, gameLA], - teams: ["team_nyc": teamNYC, "team_la": teamLA], - stadiums: ["stadium_nyc": stadiumNYC, "stadium_la": stadiumLA] + availableGames: [gameNYC, gameBOS], + teams: ["team_nyc": teamNYC, "team_bos": teamBOS], + stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS] ) let planner = ScenarioEPlanner() let result = planner.plan(request: request) - // With all regions, both games should be available - // (may still fail due to driving constraints, but games won't be region-filtered) - #expect(result.isSuccess || result.failure?.reason == .constraintsUnsatisfiable || result.failure?.reason == .noValidRoutes) + // With all regions and nearby east-coast cities, planning should succeed + guard case .success(let options) = result else { + Issue.record("Expected .success with all regions and feasible route, got \(result)") + return + } + #expect(!options.isEmpty, "Should return at least one route option") } @Test("teamFirst: empty regions includes everything") @@ -1151,14 +1189,16 @@ struct ScenarioEPlannerTests { let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston") let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) - let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! + // 2 teams → windowDuration = 4 days. Games must be within 3 days to fit in a single window. + let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)! let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc") - let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos") + let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos") let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], + numberOfDrivers: 2, selectedRegions: [], // Empty = no filtering selectedTeamIds: ["team_nyc", "team_bos"] ) @@ -1173,8 +1213,12 @@ struct ScenarioEPlannerTests { let planner = ScenarioEPlanner() let result = planner.plan(request: request) - // Empty regions = no filtering, so both games should be available - #expect(result.isSuccess || result.failure?.reason != .noGamesInRange) + // Empty regions = no filtering, so both games should be available and route feasible + guard case .success(let options) = result else { + Issue.record("Expected .success with empty regions (no filtering) and feasible route, got \(result)") + return + } + #expect(!options.isEmpty, "Should return at least one route option") } // MARK: - Past Date Filtering Tests @@ -1256,16 +1300,18 @@ struct ScenarioEPlannerTests { let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) - if case .success(let options) = result { - // All returned stops should be on or after currentDate - for option in options { - for stop in option.stops { - #expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate), - "All stops should be in the future, got \(stop.arrivalDate)") - } + guard case .success(let options) = result else { + Issue.record("Expected .success, got \(result)") + return + } + + // All returned stops should be on or after currentDate + for option in options { + for stop in option.stops { + #expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate), + "All stops should be in the future, got \(stop.arrivalDate)") } } - // Failure is acceptable if routing constraints prevent a valid route } @Test("teamFirst: evaluates all sampled windows across full season") @@ -1328,6 +1374,165 @@ struct ScenarioEPlannerTests { #expect(months.count >= 2, "Results should span at least 2 months, got months: \(months.sorted())") } + // MARK: - Output Sanity + + @Test("plan: all stop dates in the future (synthetic regression)") + func plan_allStopDatesInFuture_syntheticRegression() { + // Regression for the PHI/WSN/BAL bug: past spring training games in output + let currentDate = TestFixtures.date(year: 2026, month: 6, 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) + + // Mix of past and future games + let pastGame1 = makeGame(id: "past-nyc", homeTeamId: "yankees", awayTeamId: "opp", + stadiumId: "nyc", + dateTime: TestFixtures.date(year: 2026, month: 3, day: 10, hour: 13)) + let pastGame2 = makeGame(id: "past-bos", homeTeamId: "redsox", awayTeamId: "opp", + stadiumId: "boston", + dateTime: TestFixtures.date(year: 2026, month: 3, day: 12, hour: 13)) + let futureGame1 = makeGame(id: "future-nyc", homeTeamId: "yankees", awayTeamId: "opp", + stadiumId: "nyc", + dateTime: TestFixtures.date(year: 2026, month: 6, day: 5, hour: 19)) + let futureGame2 = makeGame(id: "future-bos", homeTeamId: "redsox", awayTeamId: "opp", + stadiumId: "boston", + dateTime: TestFixtures.date(year: 2026, month: 6, day: 7, hour: 19)) + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + numberOfDrivers: 2, + selectedTeamIds: ["yankees", "redsox"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [pastGame1, pastGame2, futureGame1, futureGame2], + teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"), + "redsox": makeTeam(id: "redsox", name: "Red Sox")], + 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 startOfDay = calendar.startOfDay(for: currentDate) + for option in options { + for stop in option.stops { + #expect(stop.arrivalDate >= startOfDay, + "Stop on \(stop.arrivalDate) is before currentDate \(startOfDay)") + } + } + } + + @Test("plan: results cover multiple months when games spread across season") + func plan_resultsCoverMultipleMonths() { + 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) + + var games: [Game] = [] + for month in 4...9 { + let dt1 = TestFixtures.date(year: 2026, month: month, day: 5, hour: 19) + let dt2 = TestFixtures.date(year: 2026, month: month, day: 7, hour: 19) + games.append(makeGame(id: "nyc-\(month)", homeTeamId: "yankees", awayTeamId: "opp", + stadiumId: "nyc", dateTime: dt1)) + games.append(makeGame(id: "bos-\(month)", homeTeamId: "redsox", awayTeamId: "opp", + stadiumId: "boston", dateTime: dt2)) + } + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + numberOfDrivers: 2, + selectedTeamIds: ["yankees", "redsox"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"), + "redsox": makeTeam(id: "redsox", name: "Red Sox")], + 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 + } + + #expect(options.count >= 2, "Should have multiple options across season") + let months = Set(options.flatMap { opt in + opt.stops.map { calendar.component(.month, from: $0.arrivalDate) } + }) + #expect(months.count >= 2, + "Results should span multiple months, got: \(months.sorted())") + } + + @Test("plan: every option has all selected teams") + func plan_everyOptionHasAllSelectedTeams_tighter() { + let currentDate = TestClock.now + + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + + var games: [Game] = [] + for day in stride(from: 1, through: 30, by: 3) { + games.append(makeGame(id: "nyc-\(day)", homeTeamId: "yankees", awayTeamId: "opp", + stadiumId: "nyc", + dateTime: TestClock.addingDays(day))) + games.append(makeGame(id: "bos-\(day)", homeTeamId: "redsox", awayTeamId: "opp", + stadiumId: "boston", + dateTime: TestClock.addingDays(day + 1))) + } + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + numberOfDrivers: 2, + selectedTeamIds: ["yankees", "redsox"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"), + "redsox": makeTeam(id: "redsox", name: "Red Sox")], + stadiums: ["nyc": nycStadium, "boston": bostonStadium] + ) + + let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f }) + 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 + } + + for (idx, option) in options.enumerated() { + let homeTeams = Set( + option.stops.flatMap { $0.games } + .compactMap { gameMap[$0]?.homeTeamId } + ) + #expect(homeTeams.contains("yankees"), + "Option \(idx): missing Yankees home game") + #expect(homeTeams.contains("redsox"), + "Option \(idx): missing Red Sox home game") + } + } + // MARK: - Helper Methods private func makeStadium( diff --git a/SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift b/SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift index 1e06abb..767d39c 100644 --- a/SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift +++ b/SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift @@ -278,15 +278,14 @@ struct TravelIntegrity_EngineGateTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for (i, option) in options.enumerated() { - #expect(option.isValid, - "Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID") - // Double-check the math - if option.stops.count > 1 { - #expect(option.travelSegments.count == option.stops.count - 1, - "Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for (i, option) in options.enumerated() { + #expect(option.isValid, + "Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID") + // Double-check the math + if option.stops.count > 1 { + #expect(option.travelSegments.count == option.stops.count - 1, + "Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)") } } } @@ -624,19 +623,18 @@ struct TravelIntegrity_EdgeCaseTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - for option in options { - for i in 0.. 1 { - #expect(option.travelSegments.count == option.stops.count - 1) - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + for option in options { + #expect(option.isValid, "Every returned option must be valid (segments = stops - 1)") + if option.stops.count > 1 { + #expect(option.travelSegments.count == option.stops.count - 1) } } } @@ -219,16 +176,15 @@ struct TripPlanningEngineTests { let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) - if case .success(let options) = result { - #expect(!options.isEmpty, "Should produce at least one option") - for option in options { - if option.stops.count > 1 { - #expect(option.travelSegments.count == option.stops.count - 1, - "Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)") - } else { - #expect(option.travelSegments.isEmpty, - "Single-stop option must have 0 segments") - } + guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return } + #expect(!options.isEmpty, "Should produce at least one option") + for option in options { + if option.stops.count > 1 { + #expect(option.travelSegments.count == option.stops.count - 1, + "Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)") + } else { + #expect(option.travelSegments.isEmpty, + "Single-stop option must have 0 segments") } } }