diff --git a/SportsTime/Core/Models/Domain/TravelSegment.swift b/SportsTime/Core/Models/Domain/TravelSegment.swift index 8957311..83c0e9c 100644 --- a/SportsTime/Core/Models/Domain/TravelSegment.swift +++ b/SportsTime/Core/Models/Domain/TravelSegment.swift @@ -58,6 +58,17 @@ struct TravelSegment: Identifiable, Codable, Hashable { var estimatedDrivingHours: Double { durationHours } var estimatedDistanceMiles: Double { distanceMiles } + /// Whether this segment requires an overnight stop based on driving time. + /// Segments over 8 hours of driving require rest. + var requiresOvernightStop: Bool { + durationHours > 8.0 + } + + /// Number of travel days this segment spans (accounting for daily driving limits). + func travelDays(maxDailyHours: Double = 8.0) -> Int { + max(1, Int(ceil(durationHours / maxDailyHours))) + } + var formattedDistance: String { String(format: "%.0f mi", distanceMiles) } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index e324a39..282f549 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -140,7 +140,8 @@ enum GameDAGRouter { constraints: DrivingConstraints, anchorGameIds: Set = [], allowRepeatCities: Bool = true, - beamWidth: Int = defaultBeamWidth + beamWidth: Int = defaultBeamWidth, + routePreference: RoutePreference = .balanced ) -> [[Game]] { // Edge cases @@ -254,7 +255,7 @@ enum GameDAGRouter { } // Step 6: Final diversity selection - let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions) + let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions, routePreference: routePreference) #if DEBUG print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)") @@ -269,6 +270,7 @@ enum GameDAGRouter { stadiums: [String: Stadium], anchorGameIds: Set = [], allowRepeatCities: Bool = true, + routePreference: RoutePreference = .balanced, stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop] ) -> [[Game]] { let constraints = DrivingConstraints.default @@ -277,7 +279,8 @@ enum GameDAGRouter { stadiums: stadiums, constraints: constraints, anchorGameIds: anchorGameIds, - allowRepeatCities: allowRepeatCities + allowRepeatCities: allowRepeatCities, + routePreference: routePreference ) } @@ -292,7 +295,8 @@ enum GameDAGRouter { private static func selectDiverseRoutes( _ routes: [[Game]], stadiums: [String: Stadium], - maxCount: Int + maxCount: Int, + routePreference: RoutePreference = .balanced ) -> [[Game]] { guard !routes.isEmpty else { return [] } @@ -319,8 +323,9 @@ enum GameDAGRouter { let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket } for bucket in byGames.keys.sorted() { if selected.count >= maxCount { break } - if let candidates = byGames[bucket]?.sorted(by: { $0.totalMiles < $1.totalMiles }) { - if let best = candidates.first, !selectedKeys.contains(best.uniqueKey) { + if let candidates = byGames[bucket] { + let sorted = sortByPreference(candidates, routePreference: routePreference) + if let best = sorted.first(where: { !selectedKeys.contains($0.uniqueKey) }) { selected.append(best) selectedKeys.insert(best.uniqueKey) } @@ -331,8 +336,10 @@ enum GameDAGRouter { let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket } for bucket in byCities.keys.sorted() { if selected.count >= maxCount { break } - if let candidates = byCities[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) { - if let best = candidates.sorted(by: { $0.totalMiles < $1.totalMiles }).first { + let candidates = (byCities[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) } + if !candidates.isEmpty { + let sorted = sortByPreference(candidates, routePreference: routePreference) + if let best = sorted.first { selected.append(best) selectedKeys.insert(best.uniqueKey) } @@ -340,8 +347,20 @@ enum GameDAGRouter { } // Pass 3: Ensure at least one route per mileage bucket + // Bias bucket iteration order based on route preference let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket } - for bucket in byMiles.keys.sorted() { + let milesBucketOrder: [Int] + switch routePreference { + case .direct: + // Prioritize low mileage buckets first + milesBucketOrder = byMiles.keys.sorted() + case .scenic: + // Prioritize high mileage buckets first (more cities = more scenic) + milesBucketOrder = byMiles.keys.sorted(by: >) + case .balanced: + milesBucketOrder = byMiles.keys.sorted() + } + for bucket in milesBucketOrder { if selected.count >= maxCount { break } if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) { if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first { @@ -355,8 +374,10 @@ enum GameDAGRouter { let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket } for bucket in byDays.keys.sorted() { if selected.count >= maxCount { break } - if let candidates = byDays[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) { - if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first { + let candidates = (byDays[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) } + if !candidates.isEmpty { + let sorted = sortByPreference(candidates, routePreference: routePreference) + if let best = sorted.first { selected.append(best) selectedKeys.insert(best.uniqueKey) } @@ -391,11 +412,24 @@ enum GameDAGRouter { if !addedAny { break } } - // Pass 6: If still need more, add remaining sorted by efficiency + // Pass 6: If still need more, add remaining sorted by route preference if selected.count < maxCount { let stillRemaining = uniqueProfiles .filter { !selectedKeys.contains($0.uniqueKey) } - .sorted { efficiency(for: $0) > efficiency(for: $1) } + .sorted { a, b in + switch routePreference { + case .direct: + // Prefer lowest mileage routes + return a.totalMiles < b.totalMiles + case .scenic: + // Prefer routes with more unique cities + if a.cityCount != b.cityCount { return a.cityCount > b.cityCount } + return a.totalMiles > b.totalMiles + case .balanced: + // Use efficiency (games per driving hour) + return efficiency(for: a) > efficiency(for: b) + } + } for profile in stillRemaining.prefix(maxCount - selected.count) { selected.append(profile) @@ -509,6 +543,27 @@ enum GameDAGRouter { return Double(profile.gameCount) / drivingHours } + /// Sorts route profiles within a bucket based on route preference. + /// - Direct: lowest mileage first + /// - Scenic: most cities first, then highest mileage + /// - Balanced: best efficiency (games per driving hour) + private static func sortByPreference( + _ profiles: [RouteProfile], + routePreference: RoutePreference + ) -> [RouteProfile] { + profiles.sorted { a, b in + switch routePreference { + case .direct: + return a.totalMiles < b.totalMiles + case .scenic: + if a.cityCount != b.cityCount { return a.cityCount > b.cityCount } + return a.totalMiles > b.totalMiles + case .balanced: + return efficiency(for: a) > efficiency(for: b) + } + } + } + // MARK: - Day Bucketing private static func bucketByDay(games: [Game]) -> [Int: [Game]] { @@ -541,8 +596,14 @@ enum GameDAGRouter { // Time must move forward guard to.startTime > from.startTime else { return false } - // Same stadium = always feasible - if from.stadiumId == to.stadiumId { return true } + // Same stadium: check for sufficient time gap between games + if from.stadiumId == to.stadiumId { + let estimatedGameDurationHours: Double = 3.0 + let departureTime = from.startTime.addingTimeInterval(estimatedGameDurationHours * 3600) + let hoursAvailable = to.startTime.timeIntervalSince(departureTime) / 3600.0 + let minGapHours: Double = 1.0 + return hoursAvailable >= minGapHours + } // Get stadiums guard let fromStadium = stadiums[from.stadiumId], @@ -621,7 +682,7 @@ enum GameDAGRouter { guard let fromStadium = stadiums[from.stadiumId], let toStadium = stadiums[to.stadiumId] else { - return 300 // Fallback estimate + return 0 // Missing stadium data — cannot estimate distance } return TravelEstimator.haversineDistanceMiles( diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index afdc9d9..b7aab5d 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -81,14 +81,21 @@ final class ScenarioAPlanner: ScenarioPlanner { // Get all games that fall within the user's travel dates. // Sort by start time so we visit them in chronological order. let selectedRegions = request.preferences.selectedRegions + var gamesWithMissingStadium = 0 let gamesInRange = request.allGames .filter { game in // Must be in date range guard dateRange.contains(game.startTime) else { return false } + // Track games with missing stadium data + guard request.stadiums[game.stadiumId] != nil else { + gamesWithMissingStadium += 1 + return false + } + // Must be in selected region (if regions specified) if !selectedRegions.isEmpty { - guard let stadium = request.stadiums[game.stadiumId] else { return false } + let stadium = request.stadiums[game.stadiumId]! let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) return selectedRegions.contains(gameRegion) } @@ -98,10 +105,18 @@ final class ScenarioAPlanner: ScenarioPlanner { // No games? Nothing to plan. if gamesInRange.isEmpty { + var violations: [ConstraintViolation] = [] + if gamesWithMissingStadium > 0 { + violations.append(ConstraintViolation( + type: .missingData, + description: "\(gamesWithMissingStadium) game(s) excluded due to missing stadium data", + severity: .warning + )) + } return .failure( PlanningFailure( reason: .noGamesInRange, - violations: [] + violations: violations ) ) } @@ -165,6 +180,7 @@ final class ScenarioAPlanner: ScenarioPlanner { from: filteredGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) validRoutes.append(contentsOf: globalRoutes) @@ -173,7 +189,8 @@ final class ScenarioAPlanner: ScenarioPlanner { let regionalRoutes = findRoutesPerRegion( games: filteredGames, stadiums: request.stadiums, - allowRepeatCities: request.preferences.allowRepeatCities + allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference ) validRoutes.append(contentsOf: regionalRoutes) @@ -478,7 +495,8 @@ final class ScenarioAPlanner: ScenarioPlanner { private func findRoutesPerRegion( games: [Game], stadiums: [String: Stadium], - allowRepeatCities: Bool + allowRepeatCities: Bool, + routePreference: RoutePreference = .balanced ) -> [[Game]] { // Partition games by region var gamesByRegion: [Region: [Game]] = [:] @@ -510,6 +528,7 @@ final class ScenarioAPlanner: ScenarioPlanner { from: regionGames, stadiums: stadiums, allowRepeatCities: allowRepeatCities, + routePreference: routePreference, stopBuilder: buildStops ) diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index b024618..ceaddc2 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -163,6 +163,7 @@ final class ScenarioBPlanner: ScenarioPlanner { stadiums: request.stadiums, anchorGameIds: anchorGameIds, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) validRoutes.append(contentsOf: globalRoutes) @@ -172,7 +173,8 @@ final class ScenarioBPlanner: ScenarioPlanner { games: gamesInRange, stadiums: request.stadiums, anchorGameIds: anchorGameIds, - allowRepeatCities: request.preferences.allowRepeatCities + allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference ) validRoutes.append(contentsOf: regionalRoutes) @@ -437,7 +439,8 @@ final class ScenarioBPlanner: ScenarioPlanner { games: [Game], stadiums: [String: Stadium], anchorGameIds: Set, - allowRepeatCities: Bool + allowRepeatCities: Bool, + routePreference: RoutePreference = .balanced ) -> [[Game]] { // First, determine which region(s) the anchor games are in var anchorRegions = Set() @@ -482,6 +485,7 @@ final class ScenarioBPlanner: ScenarioPlanner { stadiums: stadiums, anchorGameIds: regionAnchorIds, allowRepeatCities: allowRepeatCities, + routePreference: routePreference, stopBuilder: buildStops ) diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index a40187b..87e6651 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -248,6 +248,7 @@ final class ScenarioCPlanner: ScenarioPlanner { stadiums: request.stadiums, anchorGameIds: [], // No anchors in Scenario C allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift index febad1a..9efc42e 100644 --- a/SportsTime/Planning/Engine/ScenarioDPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -215,6 +215,7 @@ final class ScenarioDPlanner: ScenarioPlanner { from: finalGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) #if DEBUG diff --git a/SportsTime/Planning/Engine/ScenarioEPlanner.swift b/SportsTime/Planning/Engine/ScenarioEPlanner.swift index f06ca93..3f284fd 100644 --- a/SportsTime/Planning/Engine/ScenarioEPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioEPlanner.swift @@ -90,9 +90,16 @@ final class ScenarioEPlanner: ScenarioPlanner { // the user wants to visit each team's home stadium. var homeGamesByTeam: [String: [Game]] = [:] var allHomeGames: [Game] = [] + let selectedRegions = request.preferences.selectedRegions for game in request.allGames { if selectedTeamIds.contains(game.homeTeamId) { + // Apply region filter if regions are specified + if !selectedRegions.isEmpty { + guard let stadium = request.stadiums[game.stadiumId] else { continue } + let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) + guard selectedRegions.contains(gameRegion) else { continue } + } homeGamesByTeam[game.homeTeamId, default: []].append(game) allHomeGames.append(game) } @@ -212,6 +219,7 @@ final class ScenarioEPlanner: ScenarioPlanner { stadiums: request.stadiums, anchorGameIds: earliestAnchorIds, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) var validRoutes = candidateRoutes.filter { route in @@ -230,6 +238,7 @@ final class ScenarioEPlanner: ScenarioPlanner { stadiums: request.stadiums, anchorGameIds: latestAnchorIds, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) candidateRoutes.append(contentsOf: latestAnchorRoutes) @@ -239,6 +248,7 @@ final class ScenarioEPlanner: ScenarioPlanner { from: uniqueGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, + routePreference: request.preferences.routePreference, stopBuilder: buildStops ) candidateRoutes.append(contentsOf: noAnchorRoutes) diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 7433e9e..23b5fc0 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -17,20 +17,19 @@ import CoreLocation /// - 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) +/// - Missing coordinates → returns nil (no guessing with fallback distances) enum TravelEstimator { // MARK: - Constants private static let averageSpeedMph: Double = 60.0 private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance - private static let fallbackDistanceMiles: Double = 300.0 // MARK: - Travel Estimation @@ -44,7 +43,7 @@ enum TravelEstimator { /// /// - Expected Behavior: /// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor - /// - Missing coordinates → uses fallback distance (300 miles) + /// - Missing coordinates → returns nil (no fallback guessing) /// - Same city (no coords) → 0 distance, 0 duration /// - Driving hours > 5x maxDailyDrivingHours → returns nil /// - Duration = distance / 60 mph @@ -55,7 +54,21 @@ enum TravelEstimator { constraints: DrivingConstraints ) -> TravelSegment? { - let distanceMiles = calculateDistanceMiles(from: from, to: to) + // If either stop is missing coordinates, the segment is infeasible + // (unless same city, which returns 0 distance) + guard let distanceMiles = calculateDistanceMiles(from: from, to: to) else { + // Same city with no coords: zero-distance segment + if from.city == to.city { + return TravelSegment( + fromLocation: from.location, + toLocation: to.location, + travelMode: .drive, + distanceMeters: 0, + durationSeconds: 0 + ) + } + return nil + } let drivingHours = distanceMiles / averageSpeedMph // Maximum allowed: 5 days of driving as a conservative hard cap. @@ -126,22 +139,20 @@ enum TravelEstimator { /// - Parameters: /// - from: Origin stop /// - to: Destination stop - /// - Returns: Distance in miles + /// - Returns: Distance in miles, or nil if coordinates are missing /// /// - 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 + /// - Either missing coordinates → nil (no fallback guessing) static func calculateDistanceMiles( from: ItineraryStop, to: ItineraryStop - ) -> Double { - if let fromCoord = from.coordinate, - let toCoord = to.coordinate { - return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor + ) -> Double? { + guard let fromCoord = from.coordinate, + let toCoord = to.coordinate else { + return nil } - return estimateFallbackDistance(from: from, to: to) + return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor } /// Calculates straight-line distance in miles using Haversine formula. @@ -206,24 +217,19 @@ enum TravelEstimator { return earthRadiusMeters * c } - /// Fallback distance when coordinates aren't available. + // MARK: - Overnight Stop Detection + + /// Determines if a travel segment requires an overnight stop. /// /// - 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 - ) -> Double { - if from.city == to.city { - return 0 - } - return fallbackDistanceMiles + /// - segment: The travel segment to evaluate + /// - constraints: Driving constraints (max daily hours) + /// - Returns: true if driving hours exceed the daily limit + static func requiresOvernightStop( + segment: TravelSegment, + constraints: DrivingConstraints + ) -> Bool { + segment.estimatedDrivingHours > constraints.maxDailyDrivingHours } // MARK: - Travel Days diff --git a/SportsTime/Planning/Engine/TripPlanningEngine.swift b/SportsTime/Planning/Engine/TripPlanningEngine.swift index fe0b511..fabf20b 100644 --- a/SportsTime/Planning/Engine/TripPlanningEngine.swift +++ b/SportsTime/Planning/Engine/TripPlanningEngine.swift @@ -24,6 +24,10 @@ import Foundation /// final class TripPlanningEngine { + /// Warnings generated during the last planning run. + /// Populated when options are filtered out but valid results remain. + private(set) var warnings: [ConstraintViolation] = [] + /// Plans itineraries based on the request inputs. /// Automatically detects which scenario applies and delegates to the appropriate planner. /// @@ -31,6 +35,32 @@ final class TripPlanningEngine { /// - Returns: Ranked itineraries on success, or explicit failure with reason func planItineraries(request: PlanningRequest) -> ItineraryResult { + // Reset warnings from previous run + warnings = [] + + // Warn on empty sports set + if request.preferences.sports.isEmpty { + warnings.append(ConstraintViolation( + type: .missingData, + description: "No sports selected — results may be empty", + severity: .warning + )) + } + + // Validate date range is not inverted + if request.preferences.endDate < request.preferences.startDate { + return .failure(PlanningFailure( + reason: .missingDateRange, + violations: [ + ConstraintViolation( + type: .dateRange, + description: "End date is before start date", + severity: .error + ) + ] + )) + } + // Detect scenario and get the appropriate planner let planner = ScenarioPlannerFactory.planner(for: request) @@ -45,6 +75,7 @@ final class TripPlanningEngine { /// Applies allowRepeatCities filter after scenario planners return. /// Note: Region filtering is done during game selection in scenario planners. + /// Tracks excluded options as warnings when valid results remain. private func applyPreferenceFilters( to result: ItineraryResult, request: PlanningRequest @@ -56,6 +87,7 @@ final class TripPlanningEngine { var options = originalOptions // Filter repeat cities (this is enforced during beam search, but double-check here) + let preRepeatCount = options.count options = RouteFilters.filterRepeatCities( options, allow: request.preferences.allowRepeatCities @@ -68,7 +100,77 @@ final class TripPlanningEngine { )) } - // Region filtering is applied during game selection in scenario planners + let repeatCityExcluded = preRepeatCount - options.count + if repeatCityExcluded > 0 { + warnings.append(ConstraintViolation( + type: .general, + description: "\(repeatCityExcluded) route(s) excluded for visiting the same city on multiple days", + severity: .warning + )) + } + + // Must-stop filter: ensure all must-stop cities appear in routes + if !request.preferences.mustStopLocations.isEmpty { + let requiredCities = request.preferences.mustStopLocations + .map { $0.name.lowercased() } + .filter { !$0.isEmpty } + + if !requiredCities.isEmpty { + let preMustStopCount = options.count + options = options.filter { option in + let tripCities = Set(option.stops.map { $0.city.lowercased() }) + return requiredCities.allSatisfy { tripCities.contains($0) } + } + + if options.isEmpty { + return .failure(PlanningFailure( + reason: .noValidRoutes, + violations: [ + ConstraintViolation( + type: .mustStop, + description: "No routes include all must-stop cities", + severity: .error + ) + ] + )) + } + + let mustStopExcluded = preMustStopCount - options.count + if mustStopExcluded > 0 { + let cityList = requiredCities.joined(separator: ", ") + warnings.append(ConstraintViolation( + type: .mustStop, + description: "\(mustStopExcluded) route(s) excluded for missing must-stop cities: \(cityList)", + severity: .warning + )) + } + } + } + + // Validate travel segments: filter out invalid options + let preValidCount = options.count + options = options.filter { $0.isValid } + if options.isEmpty { + return .failure(PlanningFailure( + reason: .noValidRoutes, + violations: [ + ConstraintViolation( + type: .segmentMismatch, + description: "No valid itineraries could be built", + severity: .error + ) + ] + )) + } + + let segmentExcluded = preValidCount - options.count + if segmentExcluded > 0 { + warnings.append(ConstraintViolation( + type: .segmentMismatch, + description: "\(segmentExcluded) route(s) excluded due to invalid travel segments", + severity: .warning + )) + } return .success(options) } diff --git a/SportsTimeTests/Planning/GameDAGRouterTests.swift b/SportsTimeTests/Planning/GameDAGRouterTests.swift index e8235bb..120b0c1 100644 --- a/SportsTimeTests/Planning/GameDAGRouterTests.swift +++ b/SportsTimeTests/Planning/GameDAGRouterTests.swift @@ -537,6 +537,245 @@ struct GameDAGRouterTests { }) } + // MARK: - Route Preference Tests + + @Test("routePreference: direct prefers lower mileage routes") + func routePreference_direct_prefersLowerMileageRoutes() { + // Create games spread across cities at varying distances + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + // Create games: nearby (NYC, Boston, Philly) and far (Chicago, LA) + let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) + let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)! + let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) + let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! + let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord) + let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)! + let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord) + let game5Date = calendar.date(byAdding: .day, value: 5, to: baseDate)! + let (game5, stadium5) = makeGameAndStadium(city: "Los Angeles", date: game5Date, coord: laCoord) + + let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4, stadium5.id: stadium5] + let games = [game1, game2, game3, game4, game5] + + let directRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .direct + ) + let scenicRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .scenic + ) + + // Direct routes should exist + #expect(!directRoutes.isEmpty) + #expect(!scenicRoutes.isEmpty) + + // Compare the first route from each: direct should have lower or equal total miles + if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first { + let directMiles = totalMiles(for: directFirst, stadiums: stadiums) + let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums) + // Direct should tend toward lower mileage routes being ranked first + #expect(directMiles <= scenicMiles + 500, "Direct route should not be significantly longer than scenic") + } + } + + @Test("routePreference: scenic prefers more cities") + func routePreference_scenic_prefersMoreCitiesRoutes() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) + let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)! + let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) + let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! + let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord) + let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)! + let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord) + + let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4] + let games = [game1, game2, game3, game4] + + let scenicRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .scenic + ) + + #expect(!scenicRoutes.isEmpty) + // Scenic routes should have routes with multiple cities + let maxCities = scenicRoutes.map { route in + Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count + }.max() ?? 0 + #expect(maxCities >= 2, "Scenic should produce multi-city routes") + } + + @Test("routePreference: balanced matches default behavior") + func routePreference_balanced_matchesDefault() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) + let game2Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! + let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) + + let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2] + let games = [game1, game2] + + let balancedRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .balanced + ) + let defaultRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints + ) + + // Both should produce the same routes (balanced is default) + #expect(balancedRoutes.count == defaultRoutes.count) + } + + // MARK: - Route Preference Scoring Tests + + @Test("routePreference: direct ranks lowest-mileage routes first overall") + func routePreference_direct_ranksLowestMileageFirst() { + // Create a spread of games across East Coast + distant cities + // With enough games, the router produces diverse routes. + // Direct should surface low-mileage routes at the top. + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + var games: [Game] = [] + var stadiums: [String: Stadium] = [:] + + let cityData: [(String, CLLocationCoordinate2D)] = [ + ("New York", nycCoord), + ("Philadelphia", phillyCoord), + ("Boston", bostonCoord), + ("Chicago", chicagoCoord), + ("Seattle", seattleCoord), + ] + + for (dayOffset, (city, coord)) in cityData.enumerated() { + let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)! + let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) + games.append(game) + stadiums[stadium.id] = stadium + } + + let directRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .direct + ) + let scenicRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .scenic + ) + + #expect(!directRoutes.isEmpty) + #expect(!scenicRoutes.isEmpty) + + // Direct first route should have <= miles than scenic first route + if let dFirst = directRoutes.first, let sFirst = scenicRoutes.first { + let dMiles = totalMiles(for: dFirst, stadiums: stadiums) + let sMiles = totalMiles(for: sFirst, stadiums: stadiums) + #expect(dMiles <= sMiles, "Direct first route (\(Int(dMiles))mi) should be <= scenic first route (\(Int(sMiles))mi)") + } + } + + @Test("routePreference: scenic ranks more-cities routes first overall") + func routePreference_scenic_ranksMoreCitiesFirst() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + var games: [Game] = [] + var stadiums: [String: Stadium] = [:] + + let cityData: [(String, CLLocationCoordinate2D)] = [ + ("New York", nycCoord), + ("Philadelphia", phillyCoord), + ("Boston", bostonCoord), + ("Chicago", chicagoCoord), + ("Seattle", seattleCoord), + ] + + for (dayOffset, (city, coord)) in cityData.enumerated() { + let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)! + let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) + games.append(game) + stadiums[stadium.id] = stadium + } + + let scenicRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .scenic + ) + let directRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .direct + ) + + #expect(!scenicRoutes.isEmpty) + #expect(!directRoutes.isEmpty) + + // Scenic first route should have >= cities than direct first route + if let sFirst = scenicRoutes.first, let dFirst = directRoutes.first { + let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count + let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count + #expect(sCities >= dCities, "Scenic first route (\(sCities) cities) should be >= direct first route (\(dCities) cities)") + } + } + + @Test("routePreference: different preferences produce different route ordering") + func routePreference_differentPreferences_produceDifferentOrdering() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + var games: [Game] = [] + var stadiums: [String: Stadium] = [:] + + // Create enough games across varied distances to force diverse options + let cityData: [(String, CLLocationCoordinate2D)] = [ + ("New York", nycCoord), + ("Philadelphia", phillyCoord), + ("Boston", bostonCoord), + ("Chicago", chicagoCoord), + ("Los Angeles", laCoord), + ] + + for (dayOffset, (city, coord)) in cityData.enumerated() { + let date = calendar.date(byAdding: .day, value: dayOffset * 2, to: baseDate)! + let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) + games.append(game) + stadiums[stadium.id] = stadium + } + + let directRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .direct + ) + let scenicRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .scenic + ) + let balancedRoutes = GameDAGRouter.findRoutes( + games: games, stadiums: stadiums, constraints: constraints, + routePreference: .balanced + ) + + // All three should produce routes + #expect(!directRoutes.isEmpty) + #expect(!scenicRoutes.isEmpty) + #expect(!balancedRoutes.isEmpty) + + // With enough variety, at least two of the three should differ in first-route + let dKey = directRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" + let sKey = scenicRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" + let bKey = balancedRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" + + // With enough routes, average mileage should differ by preference + // Direct should have lower average mileage in top routes than scenic + if directRoutes.count >= 2 && scenicRoutes.count >= 2 { + let directAvgMiles = directRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, directRoutes.count)) + let scenicAvgMiles = scenicRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, scenicRoutes.count)) + #expect(directAvgMiles <= scenicAvgMiles, + "Direct top routes (\(Int(directAvgMiles))mi avg) should have <= mileage than scenic (\(Int(scenicAvgMiles))mi avg)") + } + } + // MARK: - Helper Methods private func makeGameAndStadium( @@ -601,4 +840,17 @@ struct GameDAGRouterTests { isPlayoff: false ) } + + private func totalMiles(for route: [Game], stadiums: [String: Stadium]) -> Double { + var total: Double = 0 + for i in 0..<(route.count - 1) { + guard let from = stadiums[route[i].stadiumId], + let to = stadiums[route[i+1].stadiumId] else { continue } + total += TravelEstimator.haversineDistanceMiles( + from: from.coordinate, + to: to.coordinate + ) * 1.3 + } + return total + } } diff --git a/SportsTimeTests/Planning/ItineraryBuilderTests.swift b/SportsTimeTests/Planning/ItineraryBuilderTests.swift index c6b1971..b68735c 100644 --- a/SportsTimeTests/Planning/ItineraryBuilderTests.swift +++ b/SportsTimeTests/Planning/ItineraryBuilderTests.swift @@ -303,16 +303,15 @@ struct ItineraryBuilderTests { // MARK: - Edge Case Tests - @Test("Edge: stops with nil coordinates use fallback") - func edge_nilCoordinates_useFallback() { + @Test("Edge: stops with nil coordinates are infeasible") + func edge_nilCoordinates_infeasible() { let stop1 = makeStop(city: "City1", coordinate: nil) let stop2 = makeStop(city: "City2", coordinate: nil) let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints) - // Should use fallback distance (300 miles) - #expect(result != nil) - #expect(result?.totalDistanceMiles ?? 0 > 0) + // Missing coordinates = infeasible (safer to skip than show wrong drive time) + #expect(result == nil, "Stops with missing coordinates should be infeasible") } @Test("Edge: same city stops have zero distance") diff --git a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift index 9d9fbcc..98fbdac 100644 --- a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift @@ -1058,6 +1058,125 @@ struct ScenarioEPlannerTests { } } + // MARK: - Region Filter Tests + + @Test("teamFirst: east region only excludes west games") + func teamFirst_eastRegionOnly_excludesWestGames() { + // Create two teams: one east (NYC), one also east (Boston) + let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York") + let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston") + + let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York") + let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston") + let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles") + + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! + let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! + + let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc") + let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos") + // LA game should be excluded by east-only filter + let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day3, homeTeamId: "team_nyc", stadiumId: "stadium_la") + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + selectedRegions: [.east], // East only + selectedTeamIds: ["team_nyc", "team_bos"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [gameNYC, gameBOS, gameLA], + teams: ["team_nyc": teamNYC, "team_bos": teamBOS], + stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS, "stadium_la": stadiumLA] + ) + + let planner = ScenarioEPlanner() + let result = planner.plan(request: request) + + // Should succeed — both teams have east coast games + if case .success(let options) = result { + for option in options { + let cities = option.stops.map { $0.city } + #expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA") + } + } + // If it fails, that's also acceptable since routing may not work out + } + + @Test("teamFirst: all regions includes everything") + func teamFirst_allRegions_includesEverything() { + let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York") + let teamLA = TestFixtures.team(id: "team_la", city: "Los Angeles") + + let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York") + let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles") + + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + let day5 = TestClock.calendar.date(byAdding: .day, value: 4, to: baseDate)! + + let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc") + let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day5, homeTeamId: "team_la", stadiumId: "stadium_la") + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + selectedRegions: [.east, .central, .west], // All regions + selectedTeamIds: ["team_nyc", "team_la"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [gameNYC, gameLA], + teams: ["team_nyc": teamNYC, "team_la": teamLA], + stadiums: ["stadium_nyc": stadiumNYC, "stadium_la": stadiumLA] + ) + + let planner = ScenarioEPlanner() + let result = planner.plan(request: request) + + // With all regions, both games should be available + // (may still fail due to driving constraints, but games won't be region-filtered) + #expect(result.isSuccess || result.failure?.reason == .constraintsUnsatisfiable || result.failure?.reason == .noValidRoutes) + } + + @Test("teamFirst: empty regions includes everything") + func teamFirst_emptyRegions_includesEverything() { + let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York") + let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston") + + let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York") + let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston") + + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! + + let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc") + let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos") + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + selectedRegions: [], // Empty = no filtering + selectedTeamIds: ["team_nyc", "team_bos"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [gameNYC, gameBOS], + teams: ["team_nyc": teamNYC, "team_bos": teamBOS], + stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS] + ) + + let planner = ScenarioEPlanner() + let result = planner.plan(request: request) + + // Empty regions = no filtering, so both games should be available + #expect(result.isSuccess || result.failure?.reason != .noGamesInRange) + } + // MARK: - Helper Methods private func makeStadium( diff --git a/SportsTimeTests/Planning/TravelEstimatorTests.swift b/SportsTimeTests/Planning/TravelEstimatorTests.swift index 3649402..4633e05 100644 --- a/SportsTimeTests/Planning/TravelEstimatorTests.swift +++ b/SportsTimeTests/Planning/TravelEstimatorTests.swift @@ -69,26 +69,6 @@ struct TravelEstimatorTests { #expect(abs(convertedMiles - miles) < 1.0) // Within 1 mile tolerance } - // MARK: - Specification Tests: estimateFallbackDistance - - @Test("estimateFallbackDistance: same city returns zero") - func estimateFallbackDistance_sameCity_returnsZero() { - let from = makeStop(city: "New York") - let to = makeStop(city: "New York") - - let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to) - #expect(distance == 0) - } - - @Test("estimateFallbackDistance: different cities returns 300 miles") - func estimateFallbackDistance_differentCities_returns300() { - let from = makeStop(city: "New York") - let to = makeStop(city: "Boston") - - let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to) - #expect(distance == 300) - } - // MARK: - Specification Tests: calculateDistanceMiles @Test("calculateDistanceMiles: with coordinates uses Haversine times routing factor") @@ -100,25 +80,26 @@ struct TravelEstimatorTests { let haversine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) // Road distance = Haversine * 1.3 - #expect(abs(distance - haversine * 1.3) < 0.1) + #expect(distance != nil) + #expect(abs(distance! - haversine * 1.3) < 0.1) } - @Test("calculateDistanceMiles: missing coordinates uses fallback") - func calculateDistanceMiles_missingCoordinates_usesFallback() { + @Test("calculateDistanceMiles: missing coordinates returns nil") + func calculateDistanceMiles_missingCoordinates_returnsNil() { let from = makeStop(city: "New York", coordinate: nil) let to = makeStop(city: "Boston", coordinate: nil) let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to) - #expect(distance == 300) // Fallback distance + #expect(distance == nil) } - @Test("calculateDistanceMiles: same city without coordinates returns zero") - func calculateDistanceMiles_sameCityNoCoords_returnsZero() { - let from = makeStop(city: "New York", coordinate: nil) - let to = makeStop(city: "New York", coordinate: nil) + @Test("calculateDistanceMiles: one missing coordinate returns nil") + func calculateDistanceMiles_oneMissingCoordinate_returnsNil() { + let from = makeStop(city: "New York", coordinate: nyc) + let to = makeStop(city: "Boston", coordinate: nil) let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to) - #expect(distance == 0) + #expect(distance == nil) } // MARK: - Specification Tests: estimate(ItineraryStop, ItineraryStop) @@ -142,7 +123,7 @@ struct TravelEstimatorTests { let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints)! - let expectedMiles = TravelEstimator.calculateDistanceMiles(from: from, to: to) + let expectedMiles = TravelEstimator.calculateDistanceMiles(from: from, to: to)! let expectedMeters = expectedMiles * 1609.34 let expectedHours = expectedMiles / 60.0 let expectedSeconds = expectedHours * 3600 @@ -327,7 +308,7 @@ struct TravelEstimatorTests { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) - let roadDistance = TravelEstimator.calculateDistanceMiles(from: from, to: to) + let roadDistance = TravelEstimator.calculateDistanceMiles(from: from, to: to)! let straightLine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) #expect(roadDistance >= straightLine, "Road distance should be >= straight line") diff --git a/SportsTimeTests/Planning/TripPlanningEngineTests.swift b/SportsTimeTests/Planning/TripPlanningEngineTests.swift index 94a702b..95b63f4 100644 --- a/SportsTimeTests/Planning/TripPlanningEngineTests.swift +++ b/SportsTimeTests/Planning/TripPlanningEngineTests.swift @@ -146,6 +146,159 @@ struct TripPlanningEngineTests { } } + // MARK: - Travel Segment Validation + + @Test("planTrip: multi-stop result always has travel segments") + func planTrip_multiStopResult_alwaysHasTravelSegments() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! + let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! + + let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) + let game2 = TestFixtures.game(city: "Boston", dateTime: day2) + let game3 = TestFixtures.game(city: "Philadelphia", dateTime: day3) + + let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3]) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: baseDate, + endDate: day3 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [game1, game2, game3], + teams: [:], + stadiums: stadiums + ) + + let engine = TripPlanningEngine() + let result = engine.planItineraries(request: request) + + if case .success(let options) = result { + for option in options { + #expect(option.isValid, "Every returned option must be valid (segments = stops - 1)") + if option.stops.count > 1 { + #expect(option.travelSegments.count == option.stops.count - 1) + } + } + } + } + + @Test("planTrip: N stops always have exactly N-1 travel segments") + func planTrip_nStops_haveExactlyNMinus1Segments() { + let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) + + // Create 5 games across cities to produce routes of varying lengths + let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit"] + var games: [Game] = [] + for (i, city) in cities.enumerated() { + let date = TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)! + games.append(TestFixtures.game(city: city, dateTime: date)) + } + + let stadiums = TestFixtures.stadiumMap(for: games) + let endDate = TestClock.calendar.date(byAdding: .day, value: cities.count, to: baseDate)! + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: baseDate, + endDate: endDate + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: [:], + stadiums: stadiums + ) + + let engine = TripPlanningEngine() + let result = engine.planItineraries(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty, "Should produce at least one option") + for option in options { + if option.stops.count > 1 { + #expect(option.travelSegments.count == option.stops.count - 1, + "Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)") + } else { + #expect(option.travelSegments.isEmpty, + "Single-stop option must have 0 segments") + } + } + } + } + + @Test("planTrip: invalid options are filtered out") + func planTrip_invalidOptions_areFilteredOut() { + // Create a valid ItineraryOption manually with wrong segment count + let stop1 = ItineraryStop( + city: "New York", state: "NY", + coordinate: nycCoord, + games: ["g1"], arrivalDate: Date(), departureDate: Date(), + location: LocationInput(name: "New York", coordinate: nycCoord), + firstGameStart: Date() + ) + let stop2 = ItineraryStop( + city: "Boston", state: "MA", + coordinate: bostonCoord, + games: ["g2"], arrivalDate: Date(), departureDate: Date(), + location: LocationInput(name: "Boston", coordinate: bostonCoord), + firstGameStart: Date() + ) + + // Invalid: 2 stops but 0 segments + let invalidOption = ItineraryOption( + rank: 1, stops: [stop1, stop2], + travelSegments: [], + totalDrivingHours: 0, totalDistanceMiles: 0, + geographicRationale: "test" + ) + #expect(!invalidOption.isValid, "2 stops with 0 segments should be invalid") + + // Valid: 2 stops with 1 segment + let segment = TestFixtures.travelSegment(from: "New York", to: "Boston") + let validOption = ItineraryOption( + rank: 1, stops: [stop1, stop2], + travelSegments: [segment], + totalDrivingHours: 3.5, totalDistanceMiles: 215, + geographicRationale: "test" + ) + #expect(validOption.isValid, "2 stops with 1 segment should be valid") + } + + @Test("planTrip: inverted date range returns failure") + func planTrip_invertedDateRange_returnsFailure() { + let endDate = TestFixtures.date(year: 2026, month: 6, day: 1) + let startDate = TestFixtures.date(year: 2026, month: 6, day: 10) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: startDate, + endDate: endDate + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [], + teams: [:], + stadiums: [:] + ) + + let engine = TripPlanningEngine() + let result = engine.planItineraries(request: request) + + #expect(!result.isSuccess) + if let failure = result.failure { + #expect(failure.reason == .missingDateRange) + } + } + // MARK: - Helper Methods private func makeStadium(