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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user