diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index 93509d7..a40187b 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -162,6 +162,38 @@ final class ScenarioCPlanner: ScenarioPlanner { ) } + // ────────────────────────────────────────────────────────────────── + // Step 2.5: Validate games exist at endpoint stadiums (explicit date range) + // ────────────────────────────────────────────────────────────────── + if let dateRange = request.dateRange { + let startStadiumIds = Set(startStadiums.map { $0.id }) + let endStadiumIds = Set(endStadiums.map { $0.id }) + + let hasStartGames = request.allGames.contains { + startStadiumIds.contains($0.stadiumId) && dateRange.contains($0.startTime) + } + let hasEndGames = request.allGames.contains { + endStadiumIds.contains($0.stadiumId) && dateRange.contains($0.startTime) + } + + if !hasStartGames { + return .failure(PlanningFailure( + reason: .noGamesInRange, + violations: [ConstraintViolation(type: .general, + description: "No games found at \(startLocation.name) within the selected dates", + severity: .error)] + )) + } + if !hasEndGames { + return .failure(PlanningFailure( + reason: .noGamesInRange, + violations: [ConstraintViolation(type: .general, + description: "No games found at \(endLocation.name) within the selected dates", + severity: .error)] + )) + } + } + // ────────────────────────────────────────────────────────────────── // Step 3: Generate date ranges // ────────────────────────────────────────────────────────────────── @@ -544,6 +576,8 @@ final class ScenarioCPlanner: ScenarioPlanner { } /// Builds stops with start and end location endpoints. + /// Skips adding a separate endpoint stop when the first/last game stop + /// is already in the same city, avoiding redundant 0-mile travel segments. private func buildStopsWithEndpoints( start: LocationInput, end: LocationInput, @@ -551,43 +585,58 @@ final class ScenarioCPlanner: ScenarioPlanner { stadiums: [String: Stadium] ) -> [ItineraryStop] { + // Build game stops first so we can check for city overlap + let gameStops = buildStops(from: games, stadiums: stadiums) + var stops: [ItineraryStop] = [] - // Start stop (no games) - let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date() - let startStop = ItineraryStop( - city: start.name, - state: "", - coordinate: start.coordinate, - games: [], - arrivalDate: startArrival, - departureDate: startArrival, - location: start, - firstGameStart: nil - ) - stops.append(startStop) + // Only add start endpoint if start city differs from first game stop city + let firstGameCity = gameStops.first?.city + if firstGameCity == nil || !citiesMatch(start.name, firstGameCity!) { + let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date() + let startStop = ItineraryStop( + city: start.name, + state: "", + coordinate: start.coordinate, + games: [], + arrivalDate: startArrival, + departureDate: startArrival, + location: start, + firstGameStart: nil + ) + stops.append(startStop) + } - // Game stops - let gameStops = buildStops(from: games, stadiums: stadiums) stops.append(contentsOf: gameStops) - // End stop (no games) - let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date() - let endStop = ItineraryStop( - city: end.name, - state: "", - coordinate: end.coordinate, - games: [], - arrivalDate: endArrival, - departureDate: endArrival, - location: end, - firstGameStart: nil - ) - stops.append(endStop) + // Only add end endpoint if end city differs from last game stop city + let lastGameCity = gameStops.last?.city + if lastGameCity == nil || !citiesMatch(end.name, lastGameCity!) { + let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date() + let endStop = ItineraryStop( + city: end.name, + state: "", + coordinate: end.coordinate, + games: [], + arrivalDate: endArrival, + departureDate: endArrival, + location: end, + firstGameStart: nil + ) + stops.append(endStop) + } return stops } + /// Checks if two city names refer to the same city using normalized comparison. + private func citiesMatch(_ cityA: String, _ cityB: String) -> Bool { + let a = normalizeCityName(cityA) + let b = normalizeCityName(cityB) + if a == b { return true } + return a.contains(b) || b.contains(a) + } + // MARK: - Monotonic Progress Validation /// Validates that the route makes generally forward progress toward the end. diff --git a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift index c8ab4eb..97935da 100644 --- a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift @@ -453,6 +453,325 @@ struct ScenarioCPlannerTests { #expect(true, "Forward progress tolerance documented as 15%") } + // MARK: - Regression Tests: Endpoint Merging + + private let houstonCoord = CLLocationCoordinate2D(latitude: 29.7604, longitude: -95.3698) + private let denverCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) + + @Test("plan: start city matches first game city — no redundant empty endpoint") + func plan_startCityMatchesFirstGameCity_noZeroMileTravel() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 10) + + let startLocation = LocationInput(name: "Houston, TX", coordinate: houstonCoord) + let endLocation = LocationInput(name: "New York", coordinate: nycCoord) + + let houstonStadium = makeStadium(id: "houston", city: "Houston", coordinate: houstonCoord) + let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + + let houstonGame = makeGame(id: "hou-game", stadiumId: "houston", dateTime: startDate.addingTimeInterval(86400)) + let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4)) + let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [houstonGame, clevelandGame, nycGame], + teams: [:], + stadiums: [ + "houston": houstonStadium, + "cleveland": clevelandStadium, + "nyc": nycStadium + ] + ) + + 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 } + + if !gameHoustonStops.isEmpty { + #expect(emptyHoustonStops.isEmpty, + "Should not have both a game stop and empty endpoint in Houston") + } + } + } + } + + @Test("plan: both endpoints match game cities — no redundant empty endpoints") + func plan_bothEndpointsMatchGameCities_noEmptyStops() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 10) + + let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + + let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) + let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4)) + let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chicagoGame, clevelandGame, nycGame], + teams: [:], + stadiums: [ + "chicago": chicagoStadium, + "cleveland": clevelandStadium, + "nyc": nycStadium + ] + ) + + 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") + } + + 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") + } + } + } + } + + @Test("plan: start city differs from all game cities — adds empty endpoint stop") + func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 10) + + // Start from a city that has a stadium but the route games are elsewhere + // Use Pittsburgh as an intermediate that differs from Chicago start + let pittsburghCoord = CLLocationCoordinate2D(latitude: 40.4406, longitude: -79.9959) + let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "New York", coordinate: nycCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let pittsburghStadium = makeStadium(id: "pittsburgh", city: "Pittsburgh", coordinate: pittsburghCoord) + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + + // Chicago game at start, Cleveland game at a non-endpoint city + let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) + let pittsburghGame = makeGame(id: "pit-game", stadiumId: "pittsburgh", dateTime: startDate.addingTimeInterval(86400 * 4)) + let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chicagoGame, pittsburghGame, nycGame], + teams: [:], + stadiums: [ + "chicago": chicagoStadium, + "pittsburgh": pittsburghStadium, + "nyc": nycStadium + ] + ) + + 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 } + + // 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") + } + } + } + + // MARK: - Regression Tests: Endpoint Game Validation + + @Test("plan: explicit date range with no games at end city returns failure") + func plan_explicitDateRange_noGamesAtEndCity_returnsFailure() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 7) + + let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) + + // Game at start city, but NO game at end city within date range + let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chicagoGame], + teams: [:], + stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] + ) + + let result = planner.plan(request: request) + + guard case .failure(let failure) = result else { + Issue.record("Expected failure when no games at end city within date range") + return + } + #expect(failure.reason == .noGamesInRange) + #expect(failure.violations.first?.description.contains("Cleveland") == true, + "Violation should mention end city") + } + + @Test("plan: explicit date range with no games at start city returns failure") + func plan_explicitDateRange_noGamesAtStartCity_returnsFailure() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 7) + + let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) + + // Game at end city, but NO game at start city within date range + let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [clevelandGame], + teams: [:], + stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] + ) + + let result = planner.plan(request: request) + + guard case .failure(let failure) = result else { + Issue.record("Expected failure when no games at start city within date range") + return + } + #expect(failure.reason == .noGamesInRange) + #expect(failure.violations.first?.description.contains("Chicago") == true, + "Violation should mention start city") + } + + @Test("plan: explicit date range with games at both cities succeeds") + func plan_explicitDateRange_gamesAtBothCities_succeeds() { + let startDate = TestClock.now + let endDate = startDate.addingTimeInterval(86400 * 10) + + let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) + + let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) + let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chicagoGame, clevelandGame], + teams: [:], + stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success when games exist at both endpoint cities") + return + } + #expect(!options.isEmpty, "Should produce at least one itinerary") + } + // MARK: - Helper Methods private func makeStadium(