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

@@ -17,20 +17,19 @@ import CoreLocation
/// - Constants:
/// - averageSpeedMph: 60 mph
/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance)
/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable)
///
/// - Invariants:
/// - All distance calculations are symmetric: distance(A,B) == distance(B,A)
/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0)
/// - Travel duration is always distance / averageSpeedMph
/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable)
/// - Missing coordinates returns nil (no guessing with fallback distances)
enum TravelEstimator {
// MARK: - Constants
private static let averageSpeedMph: Double = 60.0
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
private static let fallbackDistanceMiles: Double = 300.0
// MARK: - Travel Estimation
@@ -44,7 +43,7 @@ enum TravelEstimator {
///
/// - Expected Behavior:
/// - With valid coordinates calculates distance using Haversine * roadRoutingFactor
/// - Missing coordinates uses fallback distance (300 miles)
/// - Missing coordinates returns nil (no fallback guessing)
/// - Same city (no coords) 0 distance, 0 duration
/// - Driving hours > 5x maxDailyDrivingHours returns nil
/// - Duration = distance / 60 mph
@@ -55,7 +54,21 @@ enum TravelEstimator {
constraints: DrivingConstraints
) -> TravelSegment? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
// If either stop is missing coordinates, the segment is infeasible
// (unless same city, which returns 0 distance)
guard let distanceMiles = calculateDistanceMiles(from: from, to: to) else {
// Same city with no coords: zero-distance segment
if from.city == to.city {
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: 0,
durationSeconds: 0
)
}
return nil
}
let drivingHours = distanceMiles / averageSpeedMph
// Maximum allowed: 5 days of driving as a conservative hard cap.
@@ -126,22 +139,20 @@ enum TravelEstimator {
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - Returns: Distance in miles
/// - Returns: Distance in miles, or nil if coordinates are missing
///
/// - Expected Behavior:
/// - Both have coordinates Haversine distance * 1.3
/// - Either missing coordinates fallback distance
/// - Same city (no coords) 0 miles
/// - Different cities (no coords) 300 miles
/// - Either missing coordinates nil (no fallback guessing)
static func calculateDistanceMiles(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if let fromCoord = from.coordinate,
let toCoord = to.coordinate {
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
) -> Double? {
guard let fromCoord = from.coordinate,
let toCoord = to.coordinate else {
return nil
}
return estimateFallbackDistance(from: from, to: to)
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
}
/// Calculates straight-line distance in miles using Haversine formula.
@@ -206,24 +217,19 @@ enum TravelEstimator {
return earthRadiusMeters * c
}
/// Fallback distance when coordinates aren't available.
// MARK: - Overnight Stop Detection
/// Determines if a travel segment requires an overnight stop.
///
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - Returns: Estimated distance in miles
///
/// - Expected Behavior:
/// - Same city 0 miles
/// - Different cities 300 miles (fallback constant)
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
/// - segment: The travel segment to evaluate
/// - constraints: Driving constraints (max daily hours)
/// - Returns: true if driving hours exceed the daily limit
static func requiresOvernightStop(
segment: TravelSegment,
constraints: DrivingConstraints
) -> Bool {
segment.estimatedDrivingHours > constraints.maxDailyDrivingHours
}
// MARK: - Travel Days