Add implementation code for all 4 improvement plan phases

Production changes:
- TravelEstimator: remove 300mi fallback, return nil on missing coords
- TripPlanningEngine: add warnings array, empty sports warning, inverted
  date range rejection, must-stop filter, segment validation gate
- GameDAGRouter: add routePreference parameter with preference-aware
  bucket ordering and sorting in selectDiverseRoutes()
- ScenarioA-E: pass routePreference through to GameDAGRouter
- ScenarioA: track games with missing stadium data
- ScenarioE: add region filtering for home games
- TravelSegment: add requiresOvernightStop and travelDays() helpers

Test changes:
- GameDAGRouterTests: +252 lines for route preference verification
- TripPlanningEngineTests: +153 lines for segment validation, date range,
  empty sports
- ScenarioEPlannerTests: +119 lines for region filter tests
- TravelEstimatorTests: remove obsolete fallback distance tests
- ItineraryBuilderTests: update nil-coords test expectation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-03-21 09:40:32 -05:00
parent db6ab2f923
commit 6cbcef47ae
14 changed files with 807 additions and 88 deletions

View File

@@ -537,6 +537,245 @@ struct GameDAGRouterTests {
})
}
// MARK: - Route Preference Tests
@Test("routePreference: direct prefers lower mileage routes")
func routePreference_direct_prefersLowerMileageRoutes() {
// Create games spread across cities at varying distances
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
// Create games: nearby (NYC, Boston, Philly) and far (Chicago, LA)
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)!
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord)
let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)!
let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord)
let game5Date = calendar.date(byAdding: .day, value: 5, to: baseDate)!
let (game5, stadium5) = makeGameAndStadium(city: "Los Angeles", date: game5Date, coord: laCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4, stadium5.id: stadium5]
let games = [game1, game2, game3, game4, game5]
let directRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .direct
)
let scenicRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .scenic
)
// Direct routes should exist
#expect(!directRoutes.isEmpty)
#expect(!scenicRoutes.isEmpty)
// Compare the first route from each: direct should have lower or equal total miles
if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first {
let directMiles = totalMiles(for: directFirst, stadiums: stadiums)
let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums)
// Direct should tend toward lower mileage routes being ranked first
#expect(directMiles <= scenicMiles + 500, "Direct route should not be significantly longer than scenic")
}
}
@Test("routePreference: scenic prefers more cities")
func routePreference_scenic_prefersMoreCitiesRoutes() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)!
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord)
let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)!
let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4]
let games = [game1, game2, game3, game4]
let scenicRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .scenic
)
#expect(!scenicRoutes.isEmpty)
// Scenic routes should have routes with multiple cities
let maxCities = scenicRoutes.map { route in
Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
}.max() ?? 0
#expect(maxCities >= 2, "Scenic should produce multi-city routes")
}
@Test("routePreference: balanced matches default behavior")
func routePreference_balanced_matchesDefault() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord)
let game2Date = calendar.date(byAdding: .day, value: 2, to: baseDate)!
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let games = [game1, game2]
let balancedRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .balanced
)
let defaultRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints
)
// Both should produce the same routes (balanced is default)
#expect(balancedRoutes.count == defaultRoutes.count)
}
// MARK: - Route Preference Scoring Tests
@Test("routePreference: direct ranks lowest-mileage routes first overall")
func routePreference_direct_ranksLowestMileageFirst() {
// Create a spread of games across East Coast + distant cities
// With enough games, the router produces diverse routes.
// Direct should surface low-mileage routes at the top.
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
var games: [Game] = []
var stadiums: [String: Stadium] = [:]
let cityData: [(String, CLLocationCoordinate2D)] = [
("New York", nycCoord),
("Philadelphia", phillyCoord),
("Boston", bostonCoord),
("Chicago", chicagoCoord),
("Seattle", seattleCoord),
]
for (dayOffset, (city, coord)) in cityData.enumerated() {
let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)!
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
games.append(game)
stadiums[stadium.id] = stadium
}
let directRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .direct
)
let scenicRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .scenic
)
#expect(!directRoutes.isEmpty)
#expect(!scenicRoutes.isEmpty)
// Direct first route should have <= miles than scenic first route
if let dFirst = directRoutes.first, let sFirst = scenicRoutes.first {
let dMiles = totalMiles(for: dFirst, stadiums: stadiums)
let sMiles = totalMiles(for: sFirst, stadiums: stadiums)
#expect(dMiles <= sMiles, "Direct first route (\(Int(dMiles))mi) should be <= scenic first route (\(Int(sMiles))mi)")
}
}
@Test("routePreference: scenic ranks more-cities routes first overall")
func routePreference_scenic_ranksMoreCitiesFirst() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
var games: [Game] = []
var stadiums: [String: Stadium] = [:]
let cityData: [(String, CLLocationCoordinate2D)] = [
("New York", nycCoord),
("Philadelphia", phillyCoord),
("Boston", bostonCoord),
("Chicago", chicagoCoord),
("Seattle", seattleCoord),
]
for (dayOffset, (city, coord)) in cityData.enumerated() {
let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)!
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
games.append(game)
stadiums[stadium.id] = stadium
}
let scenicRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .scenic
)
let directRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .direct
)
#expect(!scenicRoutes.isEmpty)
#expect(!directRoutes.isEmpty)
// Scenic first route should have >= cities than direct first route
if let sFirst = scenicRoutes.first, let dFirst = directRoutes.first {
let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count
#expect(sCities >= dCities, "Scenic first route (\(sCities) cities) should be >= direct first route (\(dCities) cities)")
}
}
@Test("routePreference: different preferences produce different route ordering")
func routePreference_differentPreferences_produceDifferentOrdering() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
var games: [Game] = []
var stadiums: [String: Stadium] = [:]
// Create enough games across varied distances to force diverse options
let cityData: [(String, CLLocationCoordinate2D)] = [
("New York", nycCoord),
("Philadelphia", phillyCoord),
("Boston", bostonCoord),
("Chicago", chicagoCoord),
("Los Angeles", laCoord),
]
for (dayOffset, (city, coord)) in cityData.enumerated() {
let date = calendar.date(byAdding: .day, value: dayOffset * 2, to: baseDate)!
let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord)
games.append(game)
stadiums[stadium.id] = stadium
}
let directRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .direct
)
let scenicRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .scenic
)
let balancedRoutes = GameDAGRouter.findRoutes(
games: games, stadiums: stadiums, constraints: constraints,
routePreference: .balanced
)
// All three should produce routes
#expect(!directRoutes.isEmpty)
#expect(!scenicRoutes.isEmpty)
#expect(!balancedRoutes.isEmpty)
// With enough variety, at least two of the three should differ in first-route
let dKey = directRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
let sKey = scenicRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
let bKey = balancedRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? ""
// With enough routes, average mileage should differ by preference
// Direct should have lower average mileage in top routes than scenic
if directRoutes.count >= 2 && scenicRoutes.count >= 2 {
let directAvgMiles = directRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, directRoutes.count))
let scenicAvgMiles = scenicRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, scenicRoutes.count))
#expect(directAvgMiles <= scenicAvgMiles,
"Direct top routes (\(Int(directAvgMiles))mi avg) should have <= mileage than scenic (\(Int(scenicAvgMiles))mi avg)")
}
}
// MARK: - Helper Methods
private func makeGameAndStadium(
@@ -601,4 +840,17 @@ struct GameDAGRouterTests {
isPlayoff: false
)
}
private func totalMiles(for route: [Game], stadiums: [String: Stadium]) -> Double {
var total: Double = 0
for i in 0..<(route.count - 1) {
guard let from = stadiums[route[i].stadiumId],
let to = stadiums[route[i+1].stadiumId] else { continue }
total += TravelEstimator.haversineDistanceMiles(
from: from.coordinate,
to: to.coordinate
) * 1.3
}
return total
}
}