fix: ScenarioCPlanner endpoint merging and game validation

Eliminate redundant 0-mile travel segments when start/end city matches
the first/last game stop city, and fail early when no games exist at
endpoint cities within the selected date range.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-21 22:40:06 -06:00
parent 242634e03c
commit 826eadbc0f
2 changed files with 396 additions and 28 deletions

View File

@@ -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 // Step 3: Generate date ranges
// //
@@ -544,6 +576,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
} }
/// Builds stops with start and end location endpoints. /// 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( private func buildStopsWithEndpoints(
start: LocationInput, start: LocationInput,
end: LocationInput, end: LocationInput,
@@ -551,43 +585,58 @@ final class ScenarioCPlanner: ScenarioPlanner {
stadiums: [String: Stadium] stadiums: [String: Stadium]
) -> [ItineraryStop] { ) -> [ItineraryStop] {
// Build game stops first so we can check for city overlap
let gameStops = buildStops(from: games, stadiums: stadiums)
var stops: [ItineraryStop] = [] var stops: [ItineraryStop] = []
// Start stop (no games) // Only add start endpoint if start city differs from first game stop city
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date() let firstGameCity = gameStops.first?.city
let startStop = ItineraryStop( if firstGameCity == nil || !citiesMatch(start.name, firstGameCity!) {
city: start.name, let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
state: "", let startStop = ItineraryStop(
coordinate: start.coordinate, city: start.name,
games: [], state: "",
arrivalDate: startArrival, coordinate: start.coordinate,
departureDate: startArrival, games: [],
location: start, arrivalDate: startArrival,
firstGameStart: nil departureDate: startArrival,
) location: start,
stops.append(startStop) firstGameStart: nil
)
stops.append(startStop)
}
// Game stops
let gameStops = buildStops(from: games, stadiums: stadiums)
stops.append(contentsOf: gameStops) stops.append(contentsOf: gameStops)
// End stop (no games) // Only add end endpoint if end city differs from last game stop city
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date() let lastGameCity = gameStops.last?.city
let endStop = ItineraryStop( if lastGameCity == nil || !citiesMatch(end.name, lastGameCity!) {
city: end.name, let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
state: "", let endStop = ItineraryStop(
coordinate: end.coordinate, city: end.name,
games: [], state: "",
arrivalDate: endArrival, coordinate: end.coordinate,
departureDate: endArrival, games: [],
location: end, arrivalDate: endArrival,
firstGameStart: nil departureDate: endArrival,
) location: end,
stops.append(endStop) firstGameStart: nil
)
stops.append(endStop)
}
return stops 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 // MARK: - Monotonic Progress Validation
/// Validates that the route makes generally forward progress toward the end. /// Validates that the route makes generally forward progress toward the end.

View File

@@ -453,6 +453,325 @@ struct ScenarioCPlannerTests {
#expect(true, "Forward progress tolerance documented as 15%") #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 // MARK: - Helper Methods
private func makeStadium( private func makeStadium(