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:
Trey t
2026-01-16 14:07:41 -06:00
parent 035dd6f5de
commit 8162b4a029
102 changed files with 13409 additions and 9883 deletions

View File

@@ -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],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 ABA creates 3 separate stops (not 2)
///
final class ScenarioAPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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