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