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