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:
@@ -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