Implement 4-phase improvement plan with TDD verification + travel integrity tests

- Phase 1: Verify broken filter fixes (route preference, region filtering,
  must-stop, segment validation) — all already implemented, 8 TDD tests added
- Phase 2: Verify guard rails (no fallback distance, same-stadium gap,
  overnight rest, exclusion warnings) — all implemented, 12 TDD tests added
- Phase 3: Fix 2 timezone edge case tests (use fixed ET calendar), verify
  driving constraints, filter cascades, anchors, interactions — 5 tests added
- Phase 4: Add sortByRoutePreference() for post-planning re-sort, verify
  inverted date range rejection, empty sports warning, region boundaries — 8 tests
- Travel Integrity: 32 tests verifying N stops → N-1 segments invariant
  across all 5 scenario planners, ItineraryBuilder, isValid, and engine gate

New: sortByRoutePreference() on ItineraryOption (Direct/Scenic/Balanced)
Fixed: TimezoneEdgeCaseTests now timezone-independent

1199 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-03-21 09:37:19 -05:00
parent ce734b6c63
commit db6ab2f923
5 changed files with 2638 additions and 2 deletions

View File

@@ -125,6 +125,8 @@ enum ConstraintType: String, Equatable {
case selectedGames
case gameReachability
case general
case segmentMismatch
case missingData
}
enum ViolationSeverity: Equatable {
@@ -196,6 +198,70 @@ struct ItineraryOption: Identifiable {
stops.reduce(0) { $0 + $1.games.count }
}
/// Re-sorts and ranks itinerary options based on route preference.
///
/// Used to re-order results post-planning when the user toggles route preference
/// without re-running the full planner.
///
/// - Parameters:
/// - options: The itinerary options to sort
/// - routePreference: The user's route preference
/// - Returns: Sorted and ranked options (all options, no limit)
///
/// - Expected Behavior:
/// - Empty options empty result
/// - All options are returned (no filtering)
/// - Ranks are reassigned 1, 2, 3... after sorting
///
/// Sorting behavior by route preference:
/// - Direct: Lowest mileage first (minimize driving)
/// - Scenic: Most cities first, then highest mileage (maximize exploration)
/// - Balanced: Best efficiency (games per driving hour)
///
/// - Invariants:
/// - Output count == input count
/// - Ranks are sequential starting at 1
static func sortByRoutePreference(
_ options: [ItineraryOption],
routePreference: RoutePreference
) -> [ItineraryOption] {
let sorted = options.sorted { a, b in
switch routePreference {
case .direct:
// Lowest mileage first
if a.totalDistanceMiles != b.totalDistanceMiles {
return a.totalDistanceMiles < b.totalDistanceMiles
}
return a.totalDrivingHours < b.totalDrivingHours
case .scenic:
// Most unique cities first, then highest mileage
let aCities = Set(a.stops.map { $0.city }).count
let bCities = Set(b.stops.map { $0.city }).count
if aCities != bCities { return aCities > bCities }
return a.totalDistanceMiles > b.totalDistanceMiles
case .balanced:
// Best efficiency (games per driving hour)
let effA = a.totalDrivingHours > 0 ? Double(a.totalGames) / a.totalDrivingHours : Double(a.totalGames)
let effB = b.totalDrivingHours > 0 ? Double(b.totalGames) / b.totalDrivingHours : Double(b.totalGames)
if effA != effB { return effA > effB }
return a.totalGames > b.totalGames
}
}
return sorted.enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
}
/// Sorts and ranks itinerary options based on leisure level preference.
///
/// - Parameters:
@@ -431,9 +497,17 @@ extension ItineraryOption {
// Add travel segment to next stop (if not last stop)
if index < travelSegments.count {
let segment = travelSegments[index]
// Travel is location-based - just add the segment
// Multi-day travel indicated by durationHours > 8
timeline.append(.travel(segment))
// Insert overnight rest days for multi-day travel segments
let overnightRests = calculateOvernightRestDays(
for: segment,
departingStop: stop,
calendar: calendar
)
for restDay in overnightRests {
timeline.append(.rest(restDay))
}
}
}
@@ -479,6 +553,36 @@ extension ItineraryOption {
return restDays
}
/// Calculates overnight rest days needed during a multi-day travel segment.
/// When driving hours exceed a single day (8 hours), rest days are inserted.
private func calculateOvernightRestDays(
for segment: TravelSegment,
departingStop: ItineraryStop,
calendar: Calendar
) -> [RestDay] {
let drivingHours = segment.estimatedDrivingHours
let maxDailyHours = 8.0 // Default daily driving limit
guard drivingHours > maxDailyHours else { return [] }
let overnightCount = Int(ceil(drivingHours / maxDailyHours)) - 1
guard overnightCount > 0 else { return [] }
var restDays: [RestDay] = []
let departureDay = calendar.startOfDay(for: departingStop.departureDate)
for dayOffset in 1...overnightCount {
guard let restDate = calendar.date(byAdding: .day, value: dayOffset, to: departureDay) else { break }
let restDay = RestDay(
date: restDate,
location: segment.toLocation,
notes: "Overnight stop en route to \(segment.toLocation.name)"
)
restDays.append(restDay)
}
return restDays
}
/// Timeline organized by date for calendar-style display.
/// Note: Travel segments are excluded as they are location-based, not date-based.
func timelineByDate() -> [Date: [TimelineItem]] {