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

@@ -81,14 +81,21 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Get all games that fall within the user's travel dates.
// Sort by start time so we visit them in chronological order.
let selectedRegions = request.preferences.selectedRegions
var gamesWithMissingStadium = 0
let gamesInRange = request.allGames
.filter { game in
// Must be in date range
guard dateRange.contains(game.startTime) else { return false }
// Track games with missing stadium data
guard request.stadiums[game.stadiumId] != nil else {
gamesWithMissingStadium += 1
return false
}
// Must be in selected region (if regions specified)
if !selectedRegions.isEmpty {
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let stadium = request.stadiums[game.stadiumId]!
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
return selectedRegions.contains(gameRegion)
}
@@ -98,10 +105,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
// No games? Nothing to plan.
if gamesInRange.isEmpty {
var violations: [ConstraintViolation] = []
if gamesWithMissingStadium > 0 {
violations.append(ConstraintViolation(
type: .missingData,
description: "\(gamesWithMissingStadium) game(s) excluded due to missing stadium data",
severity: .warning
))
}
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: []
violations: violations
)
)
}
@@ -165,6 +180,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
from: filteredGames,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities,
routePreference: request.preferences.routePreference,
stopBuilder: buildStops
)
validRoutes.append(contentsOf: globalRoutes)
@@ -173,7 +189,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
let regionalRoutes = findRoutesPerRegion(
games: filteredGames,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities
allowRepeatCities: request.preferences.allowRepeatCities,
routePreference: request.preferences.routePreference
)
validRoutes.append(contentsOf: regionalRoutes)
@@ -478,7 +495,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
private func findRoutesPerRegion(
games: [Game],
stadiums: [String: Stadium],
allowRepeatCities: Bool
allowRepeatCities: Bool,
routePreference: RoutePreference = .balanced
) -> [[Game]] {
// Partition games by region
var gamesByRegion: [Region: [Game]] = [:]
@@ -510,6 +528,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
from: regionGames,
stadiums: stadiums,
allowRepeatCities: allowRepeatCities,
routePreference: routePreference,
stopBuilder: buildStops
)