Complete rewrite of unit test suite using TDD methodology: Planning Engine Tests: - GameDAGRouterTests: Beam search, anchor games, transitions - ItineraryBuilderTests: Stop connection, validators, EV enrichment - RouteFiltersTests: Region, time window, scoring filters - ScenarioA/B/C/D PlannerTests: All planning scenarios - TravelEstimatorTests: Distance, duration, travel days - TripPlanningEngineTests: Orchestration, caching, preferences Domain Model Tests: - AchievementDefinitionsTests, AnySportTests, DivisionTests - GameTests, ProgressTests, RegionTests, StadiumTests - TeamTests, TravelSegmentTests, TripTests, TripPollTests - TripPreferencesTests, TripStopTests, SportTests Service Tests: - FreeScoreAPITests, RouteDescriptionGeneratorTests - SuggestedTripsGeneratorTests Export Tests: - ShareableContentTests (card types, themes, dimensions) Bug fixes discovered through TDD: - ShareCardDimensions: mapSnapshotSize exceeded available width (960x480) - ScenarioBPlanner: Added anchor game validation filter All tests include: - Specification tests (expected behavior) - Invariant tests (properties that must always hold) - Edge case tests (boundary conditions) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
393 lines
16 KiB
Swift
393 lines
16 KiB
Swift
//
|
|
// ItineraryBuilder.swift
|
|
// SportsTime
|
|
//
|
|
// Shared utility for building itineraries with travel segments.
|
|
// Used by all scenario planners to convert stops into complete itineraries.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Result of building an itinerary from stops.
|
|
///
|
|
/// - Invariants:
|
|
/// - travelSegments.count == stops.count - 1 (or 0 if stops.count <= 1)
|
|
/// - totalDrivingHours >= 0
|
|
/// - totalDistanceMiles >= 0
|
|
struct BuiltItinerary {
|
|
let stops: [ItineraryStop]
|
|
let travelSegments: [TravelSegment]
|
|
let totalDrivingHours: Double
|
|
let totalDistanceMiles: Double
|
|
}
|
|
|
|
/// Shared logic for building itineraries across all scenario planners.
|
|
///
|
|
/// Connects stops with travel segments and validates feasibility.
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - Empty stops → empty itinerary (no travel)
|
|
/// - Single stop → single-stop itinerary (no travel)
|
|
/// - Multiple stops → segments between each consecutive pair
|
|
/// - Infeasible segment (exceeds driving limits) → returns nil
|
|
/// - Validator returns false → returns nil
|
|
///
|
|
/// - Invariants:
|
|
/// - travelSegments.count == stops.count - 1 for successful builds
|
|
/// - All segments pass TravelEstimator feasibility check
|
|
/// - All segments pass optional custom validator
|
|
enum ItineraryBuilder {
|
|
|
|
/// Validation that can be performed on each travel segment.
|
|
/// Return `true` if the segment is valid, `false` to reject the itinerary.
|
|
typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool
|
|
|
|
/// Builds a complete itinerary with travel segments between consecutive stops.
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Handle edge case: single stop = no travel needed
|
|
/// 2. For each consecutive pair of stops, estimate travel
|
|
/// 3. Optionally validate each segment with custom validator
|
|
/// 4. Accumulate driving hours and distance
|
|
/// 5. Verify invariant: travelSegments.count == stops.count - 1
|
|
///
|
|
/// - Parameters:
|
|
/// - stops: The stops to connect with travel segments
|
|
/// - constraints: Driving constraints (drivers, max hours per day)
|
|
/// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]")
|
|
/// - segmentValidator: Optional validation for each segment
|
|
///
|
|
/// - Returns: Built itinerary if successful, nil if any segment fails
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - Empty stops → BuiltItinerary with empty segments
|
|
/// - Single stop → BuiltItinerary with empty segments
|
|
/// - Two stops → one segment connecting them
|
|
/// - N stops → N-1 segments
|
|
/// - TravelEstimator returns nil → returns nil
|
|
/// - Validator returns false → returns nil
|
|
/// - totalDrivingHours = sum of segment.estimatedDrivingHours
|
|
/// - totalDistanceMiles = sum of segment.estimatedDistanceMiles
|
|
static func build(
|
|
stops: [ItineraryStop],
|
|
constraints: DrivingConstraints,
|
|
logPrefix: String = "[ItineraryBuilder]",
|
|
segmentValidator: SegmentValidator? = nil
|
|
) -> BuiltItinerary? {
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Edge case: Single stop or empty = no travel needed
|
|
// ──────────────────────────────────────────────────────────────────
|
|
if stops.count <= 1 {
|
|
return BuiltItinerary(
|
|
stops: stops,
|
|
travelSegments: [],
|
|
totalDrivingHours: 0,
|
|
totalDistanceMiles: 0
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Build travel segments between consecutive stops
|
|
// ──────────────────────────────────────────────────────────────────
|
|
var travelSegments: [TravelSegment] = []
|
|
var totalDrivingHours: Double = 0
|
|
var totalDistance: Double = 0
|
|
|
|
for index in 0..<(stops.count - 1) {
|
|
let fromStop = stops[index]
|
|
let toStop = stops[index + 1]
|
|
|
|
// Estimate travel for this segment
|
|
guard let segment = TravelEstimator.estimate(
|
|
from: fromStop,
|
|
to: toStop,
|
|
constraints: constraints
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
// Run optional validator (e.g., arrival time check for Scenario B)
|
|
if let validator = segmentValidator {
|
|
if !validator(segment, fromStop, toStop) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
travelSegments.append(segment)
|
|
totalDrivingHours += segment.estimatedDrivingHours
|
|
totalDistance += segment.estimatedDistanceMiles
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Verify invariant: segments = stops - 1
|
|
// ──────────────────────────────────────────────────────────────────
|
|
guard travelSegments.count == stops.count - 1 else {
|
|
return nil
|
|
}
|
|
|
|
return BuiltItinerary(
|
|
stops: stops,
|
|
travelSegments: travelSegments,
|
|
totalDrivingHours: totalDrivingHours,
|
|
totalDistanceMiles: totalDistance
|
|
)
|
|
}
|
|
|
|
// MARK: - EV Charger Enrichment
|
|
|
|
/// Enriches an itinerary option with EV charging stops along each route segment.
|
|
///
|
|
/// This is a post-processing step that can be called after the sync planning completes.
|
|
/// It fetches real routes from MapKit and searches for EV chargers along each segment.
|
|
///
|
|
/// - Parameter option: The itinerary option to enrich
|
|
/// - Returns: A new itinerary option with EV chargers populated in travel segments
|
|
///
|
|
static func enrichWithEVChargers(_ option: ItineraryOption) async -> ItineraryOption {
|
|
var enrichedSegments: [TravelSegment] = []
|
|
|
|
for segment in option.travelSegments {
|
|
// Get coordinates
|
|
guard let fromCoord = segment.fromLocation.coordinate,
|
|
let toCoord = segment.toLocation.coordinate else {
|
|
// No coordinates - keep segment as-is
|
|
enrichedSegments.append(segment)
|
|
continue
|
|
}
|
|
|
|
do {
|
|
// Fetch real route from MapKit
|
|
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
|
|
from: fromCoord,
|
|
to: toCoord
|
|
)
|
|
|
|
// Find EV chargers along the route
|
|
var evChargers: [EVChargingStop] = []
|
|
if let polyline = routeInfo.polyline {
|
|
do {
|
|
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
|
|
polyline: polyline,
|
|
searchRadiusMiles: 5.0,
|
|
intervalMiles: 100.0
|
|
)
|
|
} catch {
|
|
// EV charger search failed - continue without chargers
|
|
}
|
|
}
|
|
|
|
// Create enriched segment with real route data and EV chargers
|
|
let enrichedSegment = TravelSegment(
|
|
id: segment.id,
|
|
fromLocation: segment.fromLocation,
|
|
toLocation: segment.toLocation,
|
|
travelMode: segment.travelMode,
|
|
distanceMeters: routeInfo.distance,
|
|
durationSeconds: routeInfo.expectedTravelTime,
|
|
scenicScore: segment.scenicScore,
|
|
evChargingStops: evChargers
|
|
)
|
|
enrichedSegments.append(enrichedSegment)
|
|
|
|
} catch {
|
|
// Route calculation failed - keep original segment
|
|
enrichedSegments.append(segment)
|
|
}
|
|
}
|
|
|
|
// Recalculate totals with enriched segments
|
|
let totalDrivingHours = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours }
|
|
let totalDistanceMiles = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles }
|
|
|
|
return ItineraryOption(
|
|
rank: option.rank,
|
|
stops: option.stops,
|
|
travelSegments: enrichedSegments,
|
|
totalDrivingHours: totalDrivingHours,
|
|
totalDistanceMiles: totalDistanceMiles,
|
|
geographicRationale: option.geographicRationale
|
|
)
|
|
}
|
|
|
|
/// Enriches multiple itinerary options with EV chargers.
|
|
/// Processes in parallel for better performance.
|
|
static func enrichWithEVChargers(_ options: [ItineraryOption]) async -> [ItineraryOption] {
|
|
await withTaskGroup(of: (Int, ItineraryOption).self) { group in
|
|
for (index, option) in options.enumerated() {
|
|
group.addTask {
|
|
let enriched = await enrichWithEVChargers(option)
|
|
return (index, enriched)
|
|
}
|
|
}
|
|
|
|
var results = [(Int, ItineraryOption)]()
|
|
for await result in group {
|
|
results.append(result)
|
|
}
|
|
|
|
// Sort by original index to maintain order
|
|
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
|
|
}
|
|
}
|
|
|
|
// MARK: - Async Build with EV Chargers
|
|
|
|
/// Builds an itinerary with real route data and optional EV charger discovery.
|
|
///
|
|
/// This async version uses MapKit to get actual driving routes (instead of Haversine estimates)
|
|
/// and can optionally search for EV charging stations along each route segment.
|
|
///
|
|
/// - Parameters:
|
|
/// - stops: The stops to connect with travel segments
|
|
/// - constraints: Driving constraints (drivers, max hours per day)
|
|
/// - includeEVChargers: Whether to search for EV chargers along routes
|
|
/// - logPrefix: Prefix for log messages
|
|
/// - segmentValidator: Optional validation for each segment
|
|
///
|
|
/// - Returns: Built itinerary if successful, nil if any segment fails
|
|
///
|
|
static func buildAsync(
|
|
stops: [ItineraryStop],
|
|
constraints: DrivingConstraints,
|
|
includeEVChargers: Bool = false,
|
|
logPrefix: String = "[ItineraryBuilder]",
|
|
segmentValidator: SegmentValidator? = nil
|
|
) async -> BuiltItinerary? {
|
|
|
|
// Edge case: Single stop or empty = no travel needed
|
|
if stops.count <= 1 {
|
|
return BuiltItinerary(
|
|
stops: stops,
|
|
travelSegments: [],
|
|
totalDrivingHours: 0,
|
|
totalDistanceMiles: 0
|
|
)
|
|
}
|
|
|
|
var travelSegments: [TravelSegment] = []
|
|
var totalDrivingHours: Double = 0
|
|
var totalDistance: Double = 0
|
|
|
|
for index in 0..<(stops.count - 1) {
|
|
let fromStop = stops[index]
|
|
let toStop = stops[index + 1]
|
|
|
|
// Get coordinates
|
|
guard let fromCoord = fromStop.coordinate,
|
|
let toCoord = toStop.coordinate else {
|
|
// Fall back to estimate
|
|
guard let segment = TravelEstimator.estimate(
|
|
from: fromStop,
|
|
to: toStop,
|
|
constraints: constraints
|
|
) else {
|
|
return nil
|
|
}
|
|
travelSegments.append(segment)
|
|
totalDrivingHours += segment.estimatedDrivingHours
|
|
totalDistance += segment.estimatedDistanceMiles
|
|
continue
|
|
}
|
|
|
|
// Fetch real route from MapKit
|
|
do {
|
|
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
|
|
from: fromCoord,
|
|
to: toCoord
|
|
)
|
|
|
|
// Find EV chargers if requested and polyline is available
|
|
var evChargers: [EVChargingStop] = []
|
|
if includeEVChargers, let polyline = routeInfo.polyline {
|
|
do {
|
|
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
|
|
polyline: polyline,
|
|
searchRadiusMiles: 5.0,
|
|
intervalMiles: 100.0
|
|
)
|
|
} catch {
|
|
// Continue without chargers - not a critical failure
|
|
}
|
|
}
|
|
|
|
let segment = TravelSegment(
|
|
fromLocation: fromStop.location,
|
|
toLocation: toStop.location,
|
|
travelMode: .drive,
|
|
distanceMeters: routeInfo.distance,
|
|
durationSeconds: routeInfo.expectedTravelTime,
|
|
evChargingStops: evChargers
|
|
)
|
|
|
|
// Run optional validator
|
|
if let validator = segmentValidator {
|
|
if !validator(segment, fromStop, toStop) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
travelSegments.append(segment)
|
|
totalDrivingHours += segment.estimatedDrivingHours
|
|
totalDistance += segment.estimatedDistanceMiles
|
|
|
|
} catch {
|
|
// Fall back to estimate
|
|
guard let segment = TravelEstimator.estimate(
|
|
from: fromStop,
|
|
to: toStop,
|
|
constraints: constraints
|
|
) else {
|
|
return nil
|
|
}
|
|
travelSegments.append(segment)
|
|
totalDrivingHours += segment.estimatedDrivingHours
|
|
totalDistance += segment.estimatedDistanceMiles
|
|
}
|
|
}
|
|
|
|
// Verify invariant
|
|
guard travelSegments.count == stops.count - 1 else {
|
|
return nil
|
|
}
|
|
|
|
return BuiltItinerary(
|
|
stops: stops,
|
|
travelSegments: travelSegments,
|
|
totalDrivingHours: totalDrivingHours,
|
|
totalDistanceMiles: totalDistance
|
|
)
|
|
}
|
|
|
|
// MARK: - Common Validators
|
|
|
|
/// Validator that ensures travel duration allows arrival before game start.
|
|
/// Used by Scenario B where selected games have fixed start times.
|
|
///
|
|
/// This checks if the travel duration is short enough that the user could
|
|
/// theoretically leave after the previous game and arrive before the next.
|
|
///
|
|
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
|
|
/// - Returns: Validator closure
|
|
///
|
|
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
|
return { segment, fromStop, toStop in
|
|
guard let gameStart = toStop.firstGameStart else {
|
|
return true // No game = no constraint
|
|
}
|
|
|
|
// Check if there's enough time between departure point and game start
|
|
// Departure assumed after previous day's activities (use departure date as baseline)
|
|
let earliestDeparture = fromStop.departureDate
|
|
let travelDuration = segment.durationSeconds
|
|
let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration)
|
|
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
|
|
|
|
if earliestArrival > deadline {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|