Harden planning test suite with realistic fixtures and output sanity checks

Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests,
and updates all scenario planner tests with improved coverage and assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-04 13:38:41 -05:00
parent 188076717b
commit 9b622f8bbb
13 changed files with 2174 additions and 446 deletions

View File

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