refactor(tests): TDD rewrite of all unit tests with spec documentation
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>
This commit is contained in:
@@ -23,6 +23,26 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// DAG-based route finder for multi-game trips.
|
||||
///
|
||||
/// Finds time-respecting paths through a graph of games with driving feasibility
|
||||
/// and multi-dimensional diversity selection.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Empty games → empty result
|
||||
/// - Single game → [[game]] if no anchors or game is anchor
|
||||
/// - Two games → [[sorted]] if transition feasible and anchors satisfied
|
||||
/// - All routes are chronologically ordered
|
||||
/// - All routes respect driving constraints
|
||||
/// - Anchor games MUST appear in all returned routes
|
||||
/// - allowRepeatCities=false → no city appears twice in same route
|
||||
/// - Returns diverse set spanning games, cities, miles, and duration
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - All returned routes have games in chronological order
|
||||
/// - All games in a route have feasible transitions between consecutive games
|
||||
/// - Maximum 75 routes returned (maxOptions)
|
||||
/// - Routes with anchor games include ALL anchor games
|
||||
enum GameDAGRouter {
|
||||
|
||||
// MARK: - Configuration
|
||||
@@ -104,6 +124,16 @@ enum GameDAGRouter {
|
||||
///
|
||||
/// - Returns: Array of diverse route options
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Empty games → empty result
|
||||
/// - Single game with no anchors → [[game]]
|
||||
/// - Single game that is the anchor → [[game]]
|
||||
/// - Single game not matching anchor → []
|
||||
/// - Two feasible games → [[game1, game2]] (chronological order)
|
||||
/// - Two infeasible games (no anchors) → [[game1], [game2]] (separate routes)
|
||||
/// - Two infeasible games (with anchors) → [] (can't satisfy)
|
||||
/// - anchorGameIds must be subset of any returned route
|
||||
/// - allowRepeatCities=false filters routes with duplicate cities
|
||||
static func findRoutes(
|
||||
games: [Game],
|
||||
stadiums: [String: Stadium],
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
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]
|
||||
@@ -17,6 +22,20 @@ struct BuiltItinerary {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -40,6 +59,15 @@ enum ItineraryBuilder {
|
||||
///
|
||||
/// - 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,
|
||||
|
||||
@@ -9,12 +9,32 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Filters for itinerary options and trips.
|
||||
///
|
||||
/// Provides post-planning filtering to enforce user preferences like
|
||||
/// repeat city rules, sport filtering, and date range filtering.
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Filtering is idempotent: filter(filter(x)) == filter(x)
|
||||
/// - Empty input → empty output (preservation)
|
||||
/// - Filtering never adds items, only removes
|
||||
/// - Filter order doesn't matter for same criteria
|
||||
enum RouteFilters {
|
||||
|
||||
// MARK: - Repeat Cities Filter
|
||||
|
||||
/// Filter itinerary options that violate repeat city rules.
|
||||
/// When allowRepeatCities=false, each city must be visited on exactly ONE day.
|
||||
/// Filters itinerary options based on repeat city rules.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - options: Itinerary options to filter
|
||||
/// - allow: If true, returns all options; if false, removes options visiting any city on multiple days
|
||||
/// - Returns: Filtered options
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - allow=true → returns all options unchanged
|
||||
/// - allow=false → removes options where any city is visited on different calendar days
|
||||
/// - Empty options → returns empty array
|
||||
/// - Same city on same day (multiple stops) is allowed
|
||||
static func filterRepeatCities(
|
||||
_ options: [ItineraryOption],
|
||||
allow: Bool
|
||||
@@ -23,7 +43,16 @@ enum RouteFilters {
|
||||
return options.filter { !hasRepeatCityViolation($0) }
|
||||
}
|
||||
|
||||
/// Check if an itinerary visits any city on multiple days.
|
||||
/// Checks if an itinerary visits any city on multiple calendar days.
|
||||
///
|
||||
/// - Parameter option: Itinerary option to check
|
||||
/// - Returns: true if any city appears on more than one distinct calendar day
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Single stop → false
|
||||
/// - Multiple stops, different cities → false
|
||||
/// - Same city, same day → false (allowed)
|
||||
/// - Same city, different days → true (violation)
|
||||
static func hasRepeatCityViolation(_ option: ItineraryOption) -> Bool {
|
||||
let calendar = Calendar.current
|
||||
var cityDays: [String: Set<Date>] = [:]
|
||||
@@ -38,7 +67,16 @@ enum RouteFilters {
|
||||
return cityDays.values.contains(where: { $0.count > 1 })
|
||||
}
|
||||
|
||||
/// Get cities that are visited on multiple days (for error reporting).
|
||||
/// Gets cities that are visited on multiple days across all options.
|
||||
///
|
||||
/// - Parameter options: Itinerary options to analyze
|
||||
/// - Returns: Sorted array of city names that appear on multiple days
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Empty options → empty array
|
||||
/// - No violations → empty array
|
||||
/// - Returns unique city names, alphabetically sorted
|
||||
/// - Aggregates across all options
|
||||
static func findRepeatCities(in options: [ItineraryOption]) -> [String] {
|
||||
var violatingCities = Set<String>()
|
||||
let calendar = Calendar.current
|
||||
@@ -59,7 +97,18 @@ enum RouteFilters {
|
||||
|
||||
// MARK: - Trip List Filters
|
||||
|
||||
/// Filter trips by sport. Returns trips containing ANY of the specified sports.
|
||||
/// Filters trips by sport.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - trips: Trips to filter
|
||||
/// - sports: Sports to match
|
||||
/// - Returns: Trips containing ANY of the specified sports
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Empty sports set → returns all trips unchanged
|
||||
/// - Trip with matching sport → included
|
||||
/// - Trip with none of the sports → excluded
|
||||
/// - Trip with multiple sports, one matches → included
|
||||
static func filterBySport(_ trips: [Trip], sports: Set<Sport>) -> [Trip] {
|
||||
guard !sports.isEmpty else { return trips }
|
||||
return trips.filter { trip in
|
||||
@@ -67,7 +116,21 @@ enum RouteFilters {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter trips by date range. Returns trips that overlap with the specified range.
|
||||
/// Filters trips by date range overlap.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - trips: Trips to filter
|
||||
/// - start: Range start date
|
||||
/// - end: Range end date
|
||||
/// - Returns: Trips that overlap with the specified range
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Trip fully inside range → included
|
||||
/// - Trip partially overlapping → included
|
||||
/// - Trip fully outside range → excluded
|
||||
/// - Trip ending on range start → included (same day counts as overlap)
|
||||
/// - Trip starting on range end → included (same day counts as overlap)
|
||||
/// - Uses calendar start-of-day for comparison
|
||||
static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] {
|
||||
let calendar = Calendar.current
|
||||
let rangeStart = calendar.startOfDay(for: start)
|
||||
@@ -82,12 +145,34 @@ enum RouteFilters {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter trips by status. Returns trips matching the specified status.
|
||||
/// Filters trips by status.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - trips: Trips to filter
|
||||
/// - status: Status to match
|
||||
/// - Returns: Trips with matching status
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Returns only trips where trip.status == status
|
||||
/// - Empty trips → returns empty array
|
||||
static func filterByStatus(_ trips: [Trip], status: TripStatus) -> [Trip] {
|
||||
trips.filter { $0.status == status }
|
||||
}
|
||||
|
||||
/// Apply multiple filters. Returns intersection of all filter criteria.
|
||||
/// Applies multiple filters in sequence.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - trips: Trips to filter
|
||||
/// - sports: Optional sports filter (nil = no filter)
|
||||
/// - dateRange: Optional date range filter (nil = no filter)
|
||||
/// - status: Optional status filter (nil = no filter)
|
||||
/// - Returns: Trips matching ALL specified criteria (intersection)
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - nil criteria → skip that filter
|
||||
/// - Empty sports set → skip sport filter
|
||||
/// - Order of filters doesn't affect result
|
||||
/// - Result is intersection (AND) of all criteria
|
||||
static func applyFilters(
|
||||
_ trips: [Trip],
|
||||
sports: Set<Sport>? = nil,
|
||||
|
||||
@@ -26,6 +26,22 @@ import CoreLocation
|
||||
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
|
||||
/// Output: Single itinerary visiting LA → SF → Sacramento in order
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - No date range → returns .failure with .missingDateRange
|
||||
/// - No games in date range → returns .failure with .noGamesInRange
|
||||
/// - With selectedRegions → only includes games in those regions
|
||||
/// - With mustStopLocation → filters to home games in that city
|
||||
/// - Empty games after must-stop filter → .failure with .noGamesInRange
|
||||
/// - No valid routes from GameDAGRouter → .failure with .noValidRoutes
|
||||
/// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable
|
||||
/// - Success → returns sorted itineraries based on leisureLevel
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Returned games are always within the date range
|
||||
/// - Returned games are always chronologically ordered within each stop
|
||||
/// - buildStops groups consecutive games at the same stadium
|
||||
/// - Visiting A→B→A creates 3 separate stops (not 2)
|
||||
///
|
||||
final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
@@ -32,6 +32,23 @@ import CoreLocation
|
||||
/// Scenario B: Selected games planning
|
||||
/// Input: selected_games, date_range (or trip_duration), optional must_stop
|
||||
/// Output: Itinerary options connecting all selected games with possible bonus games
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - No selected games → returns .failure with .noValidRoutes
|
||||
/// - No valid date ranges → returns .failure with .missingDateRange
|
||||
/// - GameFirst mode → uses sliding windows with gameFirstTripDuration
|
||||
/// - Explicit dateRange (non-gameFirst) → uses that range directly
|
||||
/// - All anchor games MUST appear in every valid route
|
||||
/// - Uses arrivalBeforeGameStart validator for travel segments
|
||||
/// - No routes satisfy constraints → .failure with .constraintsUnsatisfiable
|
||||
/// - Success → returns sorted itineraries based on leisureLevel
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Every returned route contains ALL selected (anchor) games
|
||||
/// - Anchor games cannot be dropped for geographic convenience
|
||||
/// - Sliding window always spans from first to last selected game
|
||||
/// - Date ranges always contain all selected game dates
|
||||
///
|
||||
final class ScenarioBPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
@@ -142,6 +159,14 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// Deduplicate
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
// Filter routes to only include those containing ALL anchor games
|
||||
// This is critical: regional search uses filtered anchors, so we must
|
||||
// verify each route satisfies the original anchor constraint
|
||||
validRoutes = validRoutes.filter { route in
|
||||
let routeGameIds = Set(route.map { $0.id })
|
||||
return anchorGameIds.isSubset(of: routeGameIds)
|
||||
}
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
|
||||
@@ -38,6 +38,27 @@ import CoreLocation
|
||||
/// Scenario C: Directional route planning from start city to end city
|
||||
/// Input: start_location, end_location, day_span (or date_range)
|
||||
/// Output: Top 5 itinerary options with games along the directional route
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - No start location → returns .failure with .missingLocations
|
||||
/// - No end location → returns .failure with .missingLocations
|
||||
/// - Missing coordinates → returns .failure with .missingLocations
|
||||
/// - No stadiums in start city → returns .failure with .noGamesInRange
|
||||
/// - No stadiums in end city → returns .failure with .noGamesInRange
|
||||
/// - No valid date ranges → returns .failure with .missingDateRange
|
||||
/// - Directional filtering: only stadiums making forward progress included
|
||||
/// - Monotonic progress validation: route cannot backtrack significantly
|
||||
/// - Start and end locations added as non-game stops
|
||||
/// - No valid routes → .failure with .noValidRoutes
|
||||
/// - Success → returns sorted itineraries based on leisureLevel
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Start stop has no games and appears first
|
||||
/// - End stop has no games and appears last
|
||||
/// - All game stops are between start and end
|
||||
/// - Forward progress tolerance is 15%
|
||||
/// - Max detour distance is 1.5x direct distance
|
||||
///
|
||||
final class ScenarioCPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
@@ -29,6 +29,22 @@ import CoreLocation
|
||||
/// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12)
|
||||
/// Output: Route visiting Boston → Toronto → New York
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - No followTeamId → returns .failure with .missingTeamSelection
|
||||
/// - No date range → returns .failure with .missingDateRange
|
||||
/// - No team games found → returns .failure with .noGamesInRange
|
||||
/// - No games in date range/region → returns .failure with .noGamesInRange
|
||||
/// - filterToTeam returns BOTH home and away games for the team
|
||||
/// - With selectedRegions → only includes games in those regions
|
||||
/// - No valid routes → .failure with .noValidRoutes
|
||||
/// - All routes fail constraints → .failure with .constraintsUnsatisfiable
|
||||
/// - Success → returns sorted itineraries based on leisureLevel
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - All returned games have homeTeamId == teamId OR awayTeamId == teamId
|
||||
/// - Games are chronologically ordered within each stop
|
||||
/// - Duplicate routes are removed
|
||||
///
|
||||
final class ScenarioDPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol that all scenario planners must implement.
|
||||
/// Each scenario (A, B, C) has its own isolated implementation.
|
||||
/// Each scenario (A, B, C, D) has its own isolated implementation.
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Always returns either success or explicit failure, never throws
|
||||
/// - Success contains ranked itinerary options
|
||||
/// - Failure contains reason and any constraint violations
|
||||
protocol ScenarioPlanner {
|
||||
|
||||
/// Plan itineraries for this scenario.
|
||||
@@ -17,10 +22,24 @@ protocol ScenarioPlanner {
|
||||
func plan(request: PlanningRequest) -> ItineraryResult
|
||||
}
|
||||
|
||||
/// Factory for creating the appropriate scenario planner
|
||||
/// Factory for creating the appropriate scenario planner.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - followTeamId != nil → ScenarioDPlanner
|
||||
/// - selectedGames not empty → ScenarioBPlanner
|
||||
/// - startLocation AND endLocation != nil → ScenarioCPlanner
|
||||
/// - Otherwise → ScenarioAPlanner (default)
|
||||
///
|
||||
/// Priority order: D > B > C > A (first matching wins)
|
||||
enum ScenarioPlannerFactory {
|
||||
|
||||
/// Creates the appropriate planner based on the request inputs
|
||||
/// Creates the appropriate planner based on the request inputs.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - followTeamId set → ScenarioDPlanner
|
||||
/// - selectedGames not empty → ScenarioBPlanner
|
||||
/// - Both start and end locations → ScenarioCPlanner
|
||||
/// - Otherwise → ScenarioAPlanner
|
||||
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
|
||||
print("🔍 ScenarioPlannerFactory: Selecting planner...")
|
||||
print(" - followTeamId: \(request.preferences.followTeamId ?? "nil")")
|
||||
@@ -51,7 +70,13 @@ enum ScenarioPlannerFactory {
|
||||
return ScenarioAPlanner()
|
||||
}
|
||||
|
||||
/// Classifies which scenario applies to this request
|
||||
/// Classifies which scenario applies to this request.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - followTeamId set → .scenarioD
|
||||
/// - selectedGames not empty → .scenarioB
|
||||
/// - Both start and end locations → .scenarioC
|
||||
/// - Otherwise → .scenarioA
|
||||
static func classify(_ request: PlanningRequest) -> PlanningScenario {
|
||||
if request.preferences.followTeamId != nil {
|
||||
return .scenarioD
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Travel estimation utilities for calculating distances and driving times.
|
||||
///
|
||||
/// Uses Haversine formula for coordinate-based distance with a road routing factor,
|
||||
/// or fallback distances when coordinates are unavailable.
|
||||
///
|
||||
/// - 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)
|
||||
enum TravelEstimator {
|
||||
|
||||
// MARK: - Constants
|
||||
@@ -19,8 +34,21 @@ enum TravelEstimator {
|
||||
|
||||
// MARK: - Travel Estimation
|
||||
|
||||
/// Estimates a travel segment between two stops.
|
||||
/// Returns nil if trip exceeds maximum allowed driving hours (2 days worth).
|
||||
/// Estimates a travel segment between two ItineraryStops.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin stop
|
||||
/// - to: Destination stop
|
||||
/// - constraints: Driving constraints (drivers, hours per day)
|
||||
/// - Returns: TravelSegment or nil if unreachable
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor
|
||||
/// - Missing coordinates → uses fallback distance (300 miles)
|
||||
/// - Same city (no coords) → 0 distance, 0 duration
|
||||
/// - Driving hours > 5x maxDailyDrivingHours → returns nil
|
||||
/// - Duration = distance / 60 mph
|
||||
/// - Result distance in meters, duration in seconds
|
||||
static func estimate(
|
||||
from: ItineraryStop,
|
||||
to: ItineraryStop,
|
||||
@@ -47,7 +75,19 @@ enum TravelEstimator {
|
||||
}
|
||||
|
||||
/// Estimates a travel segment between two LocationInputs.
|
||||
/// Returns nil if coordinates are missing or if trip exceeds maximum allowed driving hours (2 days worth).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin location
|
||||
/// - to: Destination location
|
||||
/// - constraints: Driving constraints
|
||||
/// - Returns: TravelSegment or nil if unreachable/invalid
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Missing from.coordinate → returns nil
|
||||
/// - Missing to.coordinate → returns nil
|
||||
/// - Valid coordinates → calculates distance using Haversine * roadRoutingFactor
|
||||
/// - Driving hours > 5x maxDailyDrivingHours → returns nil
|
||||
/// - Duration = distance / 60 mph
|
||||
static func estimate(
|
||||
from: LocationInput,
|
||||
to: LocationInput,
|
||||
@@ -81,8 +121,18 @@ enum TravelEstimator {
|
||||
|
||||
// MARK: - Distance Calculations
|
||||
|
||||
/// Calculates distance in miles between two stops.
|
||||
/// Uses Haversine formula if coordinates available, fallback otherwise.
|
||||
/// Calculates road distance in miles between two ItineraryStops.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin stop
|
||||
/// - to: Destination stop
|
||||
/// - Returns: Distance in miles
|
||||
///
|
||||
/// - 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
|
||||
static func calculateDistanceMiles(
|
||||
from: ItineraryStop,
|
||||
to: ItineraryStop
|
||||
@@ -94,7 +144,18 @@ enum TravelEstimator {
|
||||
return estimateFallbackDistance(from: from, to: to)
|
||||
}
|
||||
|
||||
/// Calculates distance in miles between two coordinates using Haversine.
|
||||
/// Calculates straight-line distance in miles using Haversine formula.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin coordinate
|
||||
/// - to: Destination coordinate
|
||||
/// - Returns: Straight-line distance in miles
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Same point → 0 miles
|
||||
/// - NYC to Boston → ~190 miles (validates formula accuracy)
|
||||
/// - Symmetric: distance(A,B) == distance(B,A)
|
||||
/// - Uses Earth radius of 3958.8 miles
|
||||
static func haversineDistanceMiles(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
@@ -114,7 +175,18 @@ enum TravelEstimator {
|
||||
return earthRadiusMiles * c
|
||||
}
|
||||
|
||||
/// Calculates distance in meters between two coordinates using Haversine.
|
||||
/// Calculates straight-line distance in meters using Haversine formula.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin coordinate
|
||||
/// - to: Destination coordinate
|
||||
/// - Returns: Straight-line distance in meters
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Same point → 0 meters
|
||||
/// - Symmetric: distance(A,B) == distance(B,A)
|
||||
/// - Uses Earth radius of 6,371,000 meters
|
||||
/// - haversineDistanceMeters / 1609.34 ≈ haversineDistanceMiles
|
||||
static func haversineDistanceMeters(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
@@ -135,6 +207,15 @@ enum TravelEstimator {
|
||||
}
|
||||
|
||||
/// Fallback distance when coordinates aren't available.
|
||||
///
|
||||
/// - 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
|
||||
@@ -147,7 +228,20 @@ enum TravelEstimator {
|
||||
|
||||
// MARK: - Travel Days
|
||||
|
||||
/// Calculates which calendar days travel spans.
|
||||
/// Calculates which calendar days a driving segment spans.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - departure: Departure date/time
|
||||
/// - drivingHours: Total driving hours
|
||||
/// - Returns: Array of calendar days (start of day) that travel spans
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - 0 hours → [departure day]
|
||||
/// - 1-8 hours → [departure day] (1 day)
|
||||
/// - 8.01-16 hours → [departure day, next day] (2 days)
|
||||
/// - 16.01-24 hours → [departure day, +1, +2] (3 days)
|
||||
/// - All dates are normalized to start of day (midnight)
|
||||
/// - Assumes 8 driving hours per day max
|
||||
static func calculateTravelDays(
|
||||
departure: Date,
|
||||
drivingHours: Double
|
||||
|
||||
@@ -9,6 +9,19 @@ import Foundation
|
||||
|
||||
/// Main entry point for trip planning.
|
||||
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Uses ScenarioPlannerFactory.planner(for:) to select the right planner
|
||||
/// - Delegates entirely to the selected scenario planner
|
||||
/// - Applies repeat city filter to successful results
|
||||
/// - If all options violate repeat city constraint → .failure with .repeatCityViolation
|
||||
/// - Passes through failures from scenario planners unchanged
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Never modifies the logic of scenario planners
|
||||
/// - Always returns a result (success or failure), never throws
|
||||
/// - Repeat city filter only applied when allowRepeatCities is false
|
||||
///
|
||||
final class TripPlanningEngine {
|
||||
|
||||
/// Plans itineraries based on the request inputs.
|
||||
|
||||
@@ -10,7 +10,13 @@ import CoreLocation
|
||||
|
||||
// MARK: - Planning Scenario
|
||||
|
||||
/// Exactly one scenario per request. No blending.
|
||||
/// Planning scenario types - exactly one per request.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - scenarioA: User provides date range only, system finds games
|
||||
/// - scenarioB: User selects specific games + date range
|
||||
/// - scenarioC: User provides start/end locations, system plans route
|
||||
/// - scenarioD: User follows a team's schedule
|
||||
enum PlanningScenario: Equatable {
|
||||
case scenarioA // Date range only
|
||||
case scenarioB // Selected games + date range
|
||||
@@ -195,10 +201,19 @@ struct ItineraryOption: Identifiable {
|
||||
/// - leisureLevel: The user's leisure preference
|
||||
/// - Returns: Sorted and ranked options (all options, no limit)
|
||||
///
|
||||
/// Sorting behavior:
|
||||
/// - Packed: Most games first, then least driving
|
||||
/// - Moderate: Best efficiency (games per driving hour)
|
||||
/// - Relaxed: Least driving first, then fewer games
|
||||
/// - Expected Behavior:
|
||||
/// - Empty options → empty result
|
||||
/// - All options are returned (no filtering)
|
||||
/// - Ranks are reassigned 1, 2, 3... after sorting
|
||||
///
|
||||
/// Sorting behavior by leisure level:
|
||||
/// - Packed: Most games first, then least driving (maximize games)
|
||||
/// - Moderate: Best efficiency (games/hour), then most games (balance)
|
||||
/// - Relaxed: Least driving first, then fewer games (minimize driving)
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Output count == input count
|
||||
/// - Ranks are sequential starting at 1
|
||||
static func sortByLeisure(
|
||||
_ options: [ItineraryOption],
|
||||
leisureLevel: LeisureLevel
|
||||
@@ -270,7 +285,19 @@ struct ItineraryStop: Identifiable, Hashable {
|
||||
|
||||
// MARK: - Driving Constraints
|
||||
|
||||
/// Driving feasibility constraints.
|
||||
/// Driving feasibility constraints based on number of drivers.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - numberOfDrivers < 1 → clamped to 1
|
||||
/// - maxHoursPerDriverPerDay < 1.0 → clamped to 1.0
|
||||
/// - maxDailyDrivingHours = numberOfDrivers * maxHoursPerDriverPerDay
|
||||
/// - Default: 1 driver, 8 hours/day = 8 total hours
|
||||
/// - 2 drivers, 8 hours each = 16 total hours
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - numberOfDrivers >= 1
|
||||
/// - maxHoursPerDriverPerDay >= 1.0
|
||||
/// - maxDailyDrivingHours >= 1.0
|
||||
struct DrivingConstraints {
|
||||
let numberOfDrivers: Int
|
||||
let maxHoursPerDriverPerDay: Double
|
||||
|
||||
Reference in New Issue
Block a user