From 97367734759fbde284c01bfb28cfc742615576cc Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 13 Feb 2026 08:55:23 -0600 Subject: [PATCH] feat: improve planning engine travel handling, itinerary reordering, and scenario planners Add TravelInfo initializers and city normalization helpers to fix repeat city-pair disambiguation. Improve drag-and-drop reordering with segment index tracking and source-row-aware zone calculation. Enhance all five scenario planners with better next-day departure handling and travel segment placement. Add comprehensive tests across all planners. Co-Authored-By: Claude Opus 4.6 --- .../Core/Models/Domain/ItineraryItem.swift | 45 ++++++ .../Trip/Views/ItineraryReorderingLogic.swift | 20 +-- .../Views/ItineraryTableViewController.swift | 86 +++++++++-- .../Views/ItineraryTableViewWrapper.swift | 58 ++++---- .../Features/Trip/Views/TripDetailView.swift | 100 ++++++++----- .../Planning/Engine/GameDAGRouter.swift | 41 ++--- .../Planning/Engine/ScenarioAPlanner.swift | 97 +++++++++--- .../Planning/Engine/ScenarioBPlanner.swift | 20 +++ .../Planning/Engine/ScenarioCPlanner.swift | 34 ++++- .../Planning/Engine/ScenarioDPlanner.swift | 53 ++++++- .../Planning/Engine/ScenarioEPlanner.swift | 126 ++++++++++++---- .../Planning/Engine/TravelEstimator.swift | 8 +- SportsTimeTests/Domain/TravelInfoTests.swift | 51 +++++++ .../Planning/GameDAGRouterTests.swift | 30 ++++ .../Planning/ScenarioAPlannerTests.swift | 49 ++++++ .../Planning/ScenarioBPlannerTests.swift | 43 ++++++ .../Planning/ScenarioCPlannerTests.swift | 41 +++++ .../Planning/ScenarioDPlannerTests.swift | 57 +++++++ .../Planning/ScenarioEPlannerTests.swift | 140 ++++++++++++++++++ 19 files changed, 928 insertions(+), 171 deletions(-) create mode 100644 SportsTimeTests/Domain/TravelInfoTests.swift diff --git a/SportsTime/Core/Models/Domain/ItineraryItem.swift b/SportsTime/Core/Models/Domain/ItineraryItem.swift index 4563ff6..3394d09 100644 --- a/SportsTime/Core/Models/Domain/ItineraryItem.swift +++ b/SportsTime/Core/Models/Domain/ItineraryItem.swift @@ -42,6 +42,51 @@ struct TravelInfo: Codable, Hashable { var distanceMeters: Double? var durationSeconds: Double? + init( + fromCity: String, + toCity: String, + segmentIndex: Int? = nil, + distanceMeters: Double? = nil, + durationSeconds: Double? = nil + ) { + self.fromCity = fromCity.trimmingCharacters(in: .whitespacesAndNewlines) + self.toCity = toCity.trimmingCharacters(in: .whitespacesAndNewlines) + self.segmentIndex = segmentIndex + self.distanceMeters = distanceMeters + self.durationSeconds = durationSeconds + } + + init( + segment: TravelSegment, + segmentIndex: Int? = nil, + distanceMeters: Double? = nil, + durationSeconds: Double? = nil + ) { + self.init( + fromCity: segment.fromLocation.name, + toCity: segment.toLocation.name, + segmentIndex: segmentIndex, + distanceMeters: distanceMeters ?? segment.distanceMeters, + durationSeconds: durationSeconds ?? segment.durationSeconds + ) + } + + static func normalizeCityName(_ city: String) -> String { + city.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedFromCity: String { Self.normalizeCityName(fromCity) } + var normalizedToCity: String { Self.normalizeCityName(toCity) } + + func matches(from: String, to: String) -> Bool { + normalizedFromCity == Self.normalizeCityName(from) + && normalizedToCity == Self.normalizeCityName(to) + } + + func matches(segment: TravelSegment) -> Bool { + matches(from: segment.fromLocation.name, to: segment.toLocation.name) + } + var formattedDistance: String { guard let meters = distanceMeters else { return "" } let miles = meters / 1609.34 diff --git a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift index 0d2c5e2..836275d 100644 --- a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift +++ b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift @@ -831,21 +831,23 @@ enum ItineraryReorderingLogic { in travelValidRanges: [String: ClosedRange], model: ItineraryItem? = nil ) -> String { - let suffix = "\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + let from = TravelInfo.normalizeCityName(segment.fromLocation.name) + let to = TravelInfo.normalizeCityName(segment.toLocation.name) + let suffix = "\(from)->\(to)" let matchingKeys = travelValidRanges.keys.filter { $0.hasSuffix(suffix) } + if let segIdx = model?.travelInfo?.segmentIndex { + let indexedKey = "travel:\(segIdx):\(suffix)" + if matchingKeys.contains(indexedKey) { + return indexedKey + } + return indexedKey + } + if matchingKeys.count == 1, let key = matchingKeys.first { return key } - // Multiple matches (repeat city pair) — use segmentIndex from model to disambiguate - if let segIdx = model?.travelInfo?.segmentIndex { - let expected = "travel:\(segIdx):\(suffix)" - if matchingKeys.contains(expected) { - return expected - } - } - // Fallback: return first match or construct without index return matchingKeys.first ?? "travel:\(suffix)" } diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index a5e26df..22478de 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -321,6 +321,9 @@ final class ItineraryTableViewController: UITableViewController { /// All itinerary items (needed to build constraints) private var allItineraryItems: [ItineraryItem] = [] + + /// Canonical trip travel segment index keyed by TravelSegment UUID. + private var travelSegmentIndices: [UUID: Int] = [:] /// Trip day count for constraints private var tripDayCount: Int = 0 @@ -472,10 +475,12 @@ final class ItineraryTableViewController: UITableViewController { func reloadData( days: [ItineraryDayData], travelValidRanges: [String: ClosedRange], - itineraryItems: [ItineraryItem] = [] + itineraryItems: [ItineraryItem] = [], + travelSegmentIndices: [UUID: Int] = [:] ) { self.travelValidRanges = travelValidRanges self.allItineraryItems = itineraryItems + self.travelSegmentIndices = travelSegmentIndices self.tripDayCount = days.count // Rebuild constraints with new data @@ -571,12 +576,35 @@ final class ItineraryTableViewController: UITableViewController { /// Calculates invalid zones for a travel segment drag. /// Delegates to pure function and applies results to instance state. private func calculateTravelDragZones(segment: TravelSegment) { + let sourceRow = flatItems.firstIndex { item in + if case .travel(let rowSegment, _) = item { + return rowSegment.id == segment.id + } + return false + } ?? 0 + let zones = ItineraryReorderingLogic.calculateTravelDragZones( segment: segment, + sourceRow: sourceRow, flatItems: flatItems, travelValidRanges: travelValidRanges, constraints: constraints, - findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) } + findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }, + makeTravelItem: { [weak self] segment in + let segIdx = self?.travelSegmentIndices[segment.id] + return ItineraryItem( + tripId: self?.allItineraryItems.first?.tripId ?? UUID(), + day: 1, + sortOrder: 0, + kind: .travel( + TravelInfo( + segment: segment, + segmentIndex: segIdx + ) + ) + ) + }, + findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } ) invalidRowIndices = zones.invalidRowIndices validDropRows = zones.validDropRows @@ -586,7 +614,20 @@ final class ItineraryTableViewController: UITableViewController { /// Calculates invalid zones for a custom item drag. /// Delegates to pure function and applies results to instance state. private func calculateCustomItemDragZones(item: ItineraryItem) { - let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(item: item, flatItems: flatItems) + let sourceRow = flatItems.firstIndex { row in + if case .customItem(let current) = row { + return current.id == item.id + } + return false + } ?? 0 + + let zones = ItineraryReorderingLogic.calculateCustomItemDragZones( + item: item, + sourceRow: sourceRow, + flatItems: flatItems, + constraints: constraints, + findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } + ) invalidRowIndices = zones.invalidRowIndices validDropRows = zones.validDropRows barrierGameIds = zones.barrierGameIds @@ -597,23 +638,31 @@ final class ItineraryTableViewController: UITableViewController { /// Searches through allItineraryItems to find a matching travel item. /// Prefers matching by segmentIndex for disambiguation of repeat city pairs. private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? { - let fromLower = segment.fromLocation.name.lowercased() - let toLower = segment.toLocation.name.lowercased() + if let segIdx = travelSegmentIndices[segment.id] { + if let exact = allItineraryItems.first(where: { item in + guard case .travel(let info) = item.kind else { return false } + return info.segmentIndex == segIdx + }) { + return exact + } + } // Find all matching travel items by city pair let matches = allItineraryItems.filter { item in guard case .travel(let info) = item.kind else { return false } - return info.fromCity.lowercased() == fromLower - && info.toCity.lowercased() == toLower + return info.matches(segment: segment) } // If only one match, return it if matches.count <= 1 { return matches.first } - // Multiple matches (repeat city pair) — try to match by segment UUID identity - // The segment.id is a UUID that identifies the specific TravelSegment instance - // We match through the allItineraryItems which have segmentIndex set - return matches.first ?? nil + if let segIdx = travelSegmentIndices[segment.id] { + if let indexed = matches.first(where: { $0.travelInfo?.segmentIndex == segIdx }) { + return indexed + } + } + + return matches.first(where: { $0.travelInfo?.segmentIndex != nil }) ?? matches.first } /// Applies visual feedback during drag. @@ -767,8 +816,12 @@ final class ItineraryTableViewController: UITableViewController { // Travel is positioned within a day using sortOrder (can be before/after games) let destinationDay = dayNumber(forRow: destinationIndexPath.row) let sortOrder = calculateSortOrder(at: destinationIndexPath.row) - let segIdx = findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0 - let travelId = "travel:\(segIdx):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + let segIdx = travelSegmentIndices[segment.id] + ?? findItineraryItem(for: segment)?.travelInfo?.segmentIndex + ?? 0 + let from = TravelInfo.normalizeCityName(segment.fromLocation.name) + let to = TravelInfo.normalizeCityName(segment.toLocation.name) + let travelId = "travel:\(segIdx):\(from)->\(to)" onTravelMoved?(travelId, destinationDay, sortOrder) case .customItem(let customItem): @@ -852,13 +905,14 @@ final class ItineraryTableViewController: UITableViewController { constraints: constraints, findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }, makeTravelItem: { [weak self] segment in - ItineraryItem( + let segIdx = self?.travelSegmentIndices[segment.id] + return ItineraryItem( tripId: self?.allItineraryItems.first?.tripId ?? UUID(), day: 1, sortOrder: 0, kind: .travel(TravelInfo( - fromCity: segment.fromLocation.name, - toCity: segment.toLocation.name, + segment: segment, + segmentIndex: segIdx, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds )) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index 1ea473f..d3ea918 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -82,8 +82,13 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent controller.setTableHeader(hostingController.view) // Load initial data - let (days, validRanges, allItemsForConstraints) = buildItineraryData() - controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) + let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData() + controller.reloadData( + days: days, + travelValidRanges: validRanges, + itineraryItems: allItemsForConstraints, + travelSegmentIndices: travelSegmentIndices + ) return controller } @@ -100,15 +105,21 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent // This avoids recreating the view hierarchy and prevents infinite loops context.coordinator.headerHostingController?.rootView = headerContent - let (days, validRanges, allItemsForConstraints) = buildItineraryData() - controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) + let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData() + controller.reloadData( + days: days, + travelValidRanges: validRanges, + itineraryItems: allItemsForConstraints, + travelSegmentIndices: travelSegmentIndices + ) } // MARK: - Build Itinerary Data - private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange], [ItineraryItem]) { + private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange], [ItineraryItem], [UUID: Int]) { let tripDays = calculateTripDays() var travelValidRanges: [String: ClosedRange] = [:] + let travelSegmentIndices = Dictionary(uniqueKeysWithValues: trip.travelSegments.enumerated().map { ($1.id, $0) }) // Build game items from RichGame data for constraint validation var gameItems: [ItineraryItem] = [] @@ -183,13 +194,7 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent day: placement.day, sortOrder: placement.sortOrder, kind: .travel( - TravelInfo( - fromCity: fromCity, - toCity: toCity, - segmentIndex: segmentIndex, - distanceMeters: segment.distanceMeters, - durationSeconds: segment.durationSeconds - ) + TravelInfo(segment: segment, segmentIndex: segmentIndex) ) ) travelItems.append(travelItem) @@ -218,20 +223,13 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent .filter { $0.day == dayNum } .sorted { $0.sortOrder < $1.sortOrder } for travel in travelsForDay { - // Find the segment matching this travel by segment index (preferred) or city pair (legacy) if let info = travel.travelInfo { - let seg: TravelSegment? - if let idx = info.segmentIndex, idx < trip.travelSegments.count { - seg = trip.travelSegments[idx] - } else { - seg = trip.travelSegments.first(where: { - $0.fromLocation.name.lowercased() == info.fromCity.lowercased() - && $0.toLocation.name.lowercased() == info.toCity.lowercased() - }) - } - if let seg { - rows.append(.travel(seg, dayNumber: dayNum)) - } + guard let idx = info.segmentIndex, + idx >= 0, + idx < trip.travelSegments.count else { continue } + let seg = trip.travelSegments[idx] + guard info.matches(segment: seg) else { continue } + rows.append(.travel(seg, dayNumber: dayNum)) } } @@ -241,7 +239,7 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent switch r { case .customItem(let it): return it.sortOrder case .travel(let seg, _): - let segIdx = trip.travelSegments.firstIndex(where: { $0.id == seg.id }) ?? 0 + let segIdx = travelSegmentIndices[seg.id] ?? 0 let id = stableTravelAnchorId(seg, at: segIdx) return (travelOverrides[id]?.sortOrder) ?? (travelItems.first(where: { ti in @@ -265,8 +263,8 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent ) days.append(dayData) } - - return (days, travelValidRanges, gameItems + itineraryItems + travelItems) + + return (days, travelValidRanges, gameItems + itineraryItems + travelItems, travelSegmentIndices) } // MARK: - Helper Methods @@ -294,7 +292,9 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent } private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String { - "travel:\(index):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + let from = TravelInfo.normalizeCityName(segment.fromLocation.name) + let to = TravelInfo.normalizeCityName(segment.toLocation.name) + return "travel:\(index):\(from)->\(to)" } private func findLastGameDay(in city: String, tripDays: [Date]) -> Int { diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 7f8002e..c084061 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -797,8 +797,8 @@ struct TripDetailView: View { /// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload) private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String { - let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces) - let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces) + let from = TravelInfo.normalizeCityName(segment.fromLocation.name) + let to = TravelInfo.normalizeCityName(segment.toLocation.name) return "travel:\(index):\(from)->\(to)" } @@ -975,6 +975,17 @@ struct TripDetailView: View { return index } + /// Canonicalize travel IDs to the current segment's normalized city pair. + private func canonicalTravelAnchorId(from travelId: String) -> String? { + guard let segmentIndex = Self.parseSegmentIndex(from: travelId), + segmentIndex >= 0, + segmentIndex < trip.travelSegments.count else { + return nil + } + let segment = trip.travelSegments[segmentIndex] + return stableTravelAnchorId(segment, at: segmentIndex) + } + // MARK: - Map Helpers private func fetchDrivingRoutes() async { @@ -1259,8 +1270,7 @@ struct TripDetailView: View { day: day, sortOrder: sortOrder, kind: .travel(TravelInfo( - fromCity: segment.fromLocation.name, - toCity: segment.toLocation.name, + segment: segment, segmentIndex: segmentIndex, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds @@ -1387,22 +1397,32 @@ struct TripDetailView: View { for item in items where item.isTravel { guard let travelInfo = item.travelInfo else { continue } - let from = travelInfo.fromCity.lowercased() - let to = travelInfo.toCity.lowercased() - - if let segIdx = travelInfo.segmentIndex { - // New format with segment index - let travelId = "travel:\(segIdx):\(from)->\(to)" - overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) - } else { - // Legacy record without segment index — derive index from trip segments - if let segIdx = trip.travelSegments.firstIndex(where: { - $0.fromLocation.name.lowercased() == from && $0.toLocation.name.lowercased() == to - }) { - let travelId = "travel:\(segIdx):\(from)->\(to)" - overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) + if let segIdx = travelInfo.segmentIndex, + segIdx >= 0, + segIdx < trip.travelSegments.count { + let segment = trip.travelSegments[segIdx] + if !travelInfo.matches(segment: segment) { + print("⚠️ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities") } + let travelId = stableTravelAnchorId(segment, at: segIdx) + overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) + continue } + + // Legacy record without segment index: only accept if the city pair maps uniquely. + let matches = trip.travelSegments.enumerated().filter { _, segment in + travelInfo.matches(segment: segment) + } + + guard matches.count == 1, let match = matches.first else { + print("⚠️ [TravelOverrides] Ignoring ambiguous legacy travel override \(travelInfo.fromCity)->\(travelInfo.toCity)") + continue + } + + let segIdx = match.offset + let segment = match.element + let travelId = stableTravelAnchorId(segment, at: segIdx) + overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) } travelOverrides = overrides @@ -1478,8 +1498,11 @@ struct TripDetailView: View { Task { @MainActor in // Check if this is a travel segment being dropped if droppedId.hasPrefix("travel:") { + guard let canonicalTravelId = self.canonicalTravelAnchorId(from: droppedId) else { + return + } // Validate travel is within valid bounds (day-level) - if let validRange = self.validDayRange(for: droppedId) { + if let validRange = self.validDayRange(for: canonicalTravelId) { guard validRange.contains(dayNumber) else { return } @@ -1499,12 +1522,12 @@ struct TripDetailView: View { let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0) withAnimation { - self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder) + self.travelOverrides[canonicalTravelId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder) } // Persist to CloudKit as a travel ItineraryItem await self.saveTravelDayOverride( - travelAnchorId: droppedId, + travelAnchorId: canonicalTravelId, displayDay: dayNumber, sortOrder: newSortOrder ) @@ -1531,46 +1554,40 @@ struct TripDetailView: View { private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async { print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)") - // Parse travel ID (format: "travel:INDEX:from->to") - let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId) - let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "") - let colonParts = stripped.components(separatedBy: ":") - // After removing "travel:", format is "INDEX:from->to" - let cityPart = colonParts.count >= 2 ? colonParts.dropFirst().joined(separator: ":") : stripped - let cityParts = cityPart.components(separatedBy: "->") - guard cityParts.count == 2 else { - print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)") + guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId), + segmentIndex >= 0, + segmentIndex < trip.travelSegments.count else { + print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)") return } - let fromCity = cityParts[0] - let toCity = cityParts[1] + let segment = trip.travelSegments[segmentIndex] + let canonicalTravelId = stableTravelAnchorId(segment, at: segmentIndex) + let canonicalInfo = TravelInfo(segment: segment, segmentIndex: segmentIndex) - // Find existing travel item matching by segment index (preferred) or city pair (legacy) + // Find existing travel item matching by segment index (preferred) or city pair (legacy fallback). if let existingIndex = itineraryItems.firstIndex(where: { guard $0.isTravel, let info = $0.travelInfo else { return false } - // Match by segment index if available - if let idx = segmentIndex, let itemIdx = info.segmentIndex { - return idx == itemIdx + if let itemIdx = info.segmentIndex { + return itemIdx == segmentIndex } - // Legacy fallback: match by city pair - return info.fromCity.lowercased() == fromCity && info.toCity.lowercased() == toCity + return info.matches(segment: segment) }) { // Update existing var updated = itineraryItems[existingIndex] updated.day = displayDay updated.sortOrder = sortOrder updated.modifiedAt = Date() + updated.kind = .travel(canonicalInfo) itineraryItems[existingIndex] = updated await ItineraryItemService.shared.updateItem(updated) } else { // Create new travel item - let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity, segmentIndex: segmentIndex) let item = ItineraryItem( tripId: trip.id, day: displayDay, sortOrder: sortOrder, - kind: .travel(travelInfo) + kind: .travel(canonicalInfo) ) itineraryItems.append(item) do { @@ -1580,6 +1597,9 @@ struct TripDetailView: View { return } } + if canonicalTravelId != travelAnchorId { + print("ℹ️ [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)") + } print("✅ [TravelOverrides] Saved to CloudKit") } } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 453bc1c..3a8fc12 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -71,9 +71,6 @@ enum GameDAGRouter { /// Buffer time after game ends before we can depart (hours) private static let gameEndBufferHours: Double = 3.0 - /// Maximum days ahead to consider for next game (1 = next day only, 5 = allows multi-day drives) - private static let maxDayLookahead = 5 - // MARK: - Route Profile /// Captures the key metrics of a route for diversity analysis @@ -176,14 +173,29 @@ enum GameDAGRouter { // Step 2.5: Calculate effective beam width for this dataset size let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth) - // Step 3: Initialize beam with first few days' games as starting points - var beam: [[Game]] = [] - for dayIndex in sortedDays.prefix(maxDayLookahead) { - if let dayGames = buckets[dayIndex] { - for game in dayGames { - beam.append([game]) - } - } + // Step 3: Initialize beam from all games so later-starting valid routes + // (including anchor-driven routes) are not dropped up front. + let initialBeam = sortedGames.map { [$0] } + let beamSeedLimit = max(scaledBeamWidth * 2, 50) + + let anchorSeeds = initialBeam.filter { path in + guard let game = path.first else { return false } + return anchorGameIds.contains(game.id) + } + let nonAnchorSeeds = initialBeam.filter { path in + guard let game = path.first else { return false } + return !anchorGameIds.contains(game.id) + } + + let reservedForAnchors = min(anchorSeeds.count, beamSeedLimit) + let remainingSlots = max(0, beamSeedLimit - reservedForAnchors) + let prunedNonAnchorSeeds = remainingSlots > 0 + ? diversityPrune(nonAnchorSeeds, stadiums: stadiums, targetCount: remainingSlots) + : [] + + var beam = Array(anchorSeeds.prefix(reservedForAnchors)) + prunedNonAnchorSeeds + if beam.isEmpty { + beam = diversityPrune(initialBeam, stadiums: stadiums, targetCount: beamSeedLimit) } // Step 4: Expand beam day by day with early termination @@ -200,13 +212,6 @@ enum GameDAGRouter { for path in beam { guard let lastGame = path.last else { continue } - let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime) - - // Skip if this day is too far ahead for this route - if dayIndex > lastGameDay + maxDayLookahead { - nextBeam.append(path) - continue - } // Try adding each of today's games for candidate in todaysGames { diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 4225009..2112994 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -15,7 +15,7 @@ import CoreLocation /// /// Input: /// - date_range: Required. The trip dates (e.g., Jan 5-15) -/// - must_stop: Optional. A location they must visit (filters to home games in that city) +/// - must_stop: Optional. One or more locations the route must include /// /// Output: /// - Success: Ranked list of itinerary options @@ -30,8 +30,8 @@ import CoreLocation /// - 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 +/// - With mustStopLocations → route must include at least one game in each must-stop city +/// - Missing games for any must-stop city → .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 @@ -109,33 +109,36 @@ final class ScenarioAPlanner: ScenarioPlanner { // ────────────────────────────────────────────────────────────────── // Step 2b: Filter by must-stop locations (if any) // ────────────────────────────────────────────────────────────────── - // If user specified a must-stop city, filter to HOME games in that city. - // A "home game" means the stadium is in the must-stop city. - var filteredGames = gamesInRange - if let mustStop = request.mustStopLocation { - let mustStopCity = mustStop.name.lowercased() - filteredGames = gamesInRange.filter { game in - guard let stadium = request.stadiums[game.stadiumId] else { return false } - let stadiumCity = stadium.city.lowercased() - // Match if either contains the other (handles "Chicago" vs "Chicago, IL") - return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity) + // Must-stops are route constraints, not exclusive filters. + // Keep all games in range, then require routes to include each must-stop city. + let requiredMustStops = request.preferences.mustStopLocations.filter { stop in + !normalizeCityName(stop.name).isEmpty + } + + if !requiredMustStops.isEmpty { + let missingMustStops = requiredMustStops.filter { mustStop in + !gamesInRange.contains { game in + gameMatchesCity(game, cityName: mustStop.name, stadiums: request.stadiums) + } } - if filteredGames.isEmpty { + if !missingMustStops.isEmpty { + let violations = missingMustStops.map { missing in + ConstraintViolation( + type: .mustStop, + description: "No home games found in \(missing.name) during selected dates", + severity: .error + ) + } return .failure( PlanningFailure( reason: .noGamesInRange, - violations: [ - ConstraintViolation( - type: .mustStop, - description: "No home games found in \(mustStop.name) during selected dates", - severity: .error - ) - ] + violations: violations ) ) } } + let filteredGames = gamesInRange // ────────────────────────────────────────────────────────────────── // Step 3: Find ALL geographically sensible route variations @@ -177,6 +180,18 @@ final class ScenarioAPlanner: ScenarioPlanner { // Deduplicate routes (same game IDs) validRoutes = deduplicateRoutes(validRoutes) + // Enforce must-stop coverage after route generation so non-must-stop games can + // still be included as connective "bonus" cities. + if !requiredMustStops.isEmpty { + validRoutes = validRoutes.filter { route in + routeSatisfiesMustStops( + route, + mustStops: requiredMustStops, + stadiums: request.stadiums + ) + } + } + print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)") if let firstRoute = validRoutes.first { print("🔍 ScenarioA: First route has \(firstRoute.count) games") @@ -185,13 +200,16 @@ final class ScenarioAPlanner: ScenarioPlanner { } if validRoutes.isEmpty { + let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty return .failure( PlanningFailure( reason: .noValidRoutes, violations: [ ConstraintViolation( - type: .geographicSanity, - description: "No geographically sensible route found for games in this date range", + type: noMustStopSatisfyingRoutes ? .mustStop : .geographicSanity, + description: noMustStopSatisfyingRoutes + ? "No valid route can include all required must-stop cities" + : "No geographically sensible route found for games in this date range", severity: .error ) ] @@ -406,6 +424,39 @@ final class ScenarioAPlanner: ScenarioPlanner { return unique } + private func routeSatisfiesMustStops( + _ route: [Game], + mustStops: [LocationInput], + stadiums: [String: Stadium] + ) -> Bool { + mustStops.allSatisfy { mustStop in + route.contains { game in + gameMatchesCity(game, cityName: mustStop.name, stadiums: stadiums) + } + } + } + + private func gameMatchesCity( + _ game: Game, + cityName: String, + stadiums: [String: Stadium] + ) -> Bool { + guard let stadium = stadiums[game.stadiumId] else { return false } + let targetCity = normalizeCityName(cityName) + let stadiumCity = normalizeCityName(stadium.city) + guard !targetCity.isEmpty, !stadiumCity.isEmpty else { return false } + return stadiumCity == targetCity || stadiumCity.contains(targetCity) || targetCity.contains(stadiumCity) + } + + private func normalizeCityName(_ value: String) -> String { + let cityPart = value.split(separator: ",", maxSplits: 1).first.map(String.init) ?? value + return cityPart + .lowercased() + .replacingOccurrences(of: ".", with: "") + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + } + // MARK: - Regional Route Finding /// Finds routes by running beam search separately for each geographic region. diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index 097e957..559f7bc 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -75,6 +75,26 @@ final class ScenarioBPlanner: ScenarioPlanner { ) } + // In explicit date-range mode, fail fast if selected anchors are out of range. + let isGameFirstMode = request.preferences.planningMode == .gameFirst + if !isGameFirstMode, let explicitRange = request.dateRange { + let outOfRangeAnchors = selectedGames.filter { !explicitRange.contains($0.startTime) } + if !outOfRangeAnchors.isEmpty { + return .failure( + PlanningFailure( + reason: .dateRangeViolation(games: outOfRangeAnchors), + violations: [ + ConstraintViolation( + type: .dateRange, + description: "\(outOfRangeAnchors.count) selected game(s) are outside the requested date range", + severity: .error + ) + ] + ) + ) + } + } + // ────────────────────────────────────────────────────────────────── // Step 2: Generate date ranges (sliding window or single range) // ────────────────────────────────────────────────────────────────── diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index 1d39401..4ac48f8 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -295,12 +295,42 @@ final class ScenarioCPlanner: ScenarioPlanner { cityName: String, stadiums: [String: Stadium] ) -> [Stadium] { - let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces) + let normalizedCity = normalizeCityName(cityName) return stadiums.values.filter { stadium in - stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity + let normalizedStadiumCity = normalizeCityName(stadium.city) + if normalizedStadiumCity == normalizedCity { return true } + return normalizedStadiumCity.contains(normalizedCity) || normalizedCity.contains(normalizedStadiumCity) } } + /// Normalizes city labels for resilient user-input matching. + private func normalizeCityName(_ raw: String) -> String { + // Keep the city component before state suffixes like "City, ST". + let cityPart = raw.split(separator: ",", maxSplits: 1).first.map(String.init) ?? raw + var normalized = cityPart + .lowercased() + .replacingOccurrences(of: ".", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let aliases: [String: String] = [ + "nyc": "new york", + "new york city": "new york", + "la": "los angeles", + "sf": "san francisco", + "dc": "washington", + "washington dc": "washington" + ] + + if let aliased = aliases[normalized] { + normalized = aliased + } + + // Collapse repeated spaces after punctuation/alias normalization. + return normalized + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + } + /// Finds stadiums that make forward progress from start to end. /// /// A stadium is "directional" if visiting it doesn't significantly increase diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift index e0505eb..f9fae80 100644 --- a/SportsTime/Planning/Engine/ScenarioDPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -18,7 +18,7 @@ import CoreLocation /// - date_range: Required. The trip dates. /// - selectedRegions: Optional. Filter to specific regions. /// - useHomeLocation: Whether to start/end from user's home. -/// - startLocation: Required if useHomeLocation is true. +/// - startLocation: Used as start/end home stop when provided. /// /// Output: /// - Success: Ranked list of itinerary options @@ -173,6 +173,11 @@ final class ScenarioDPlanner: ScenarioPlanner { // ────────────────────────────────────────────────────────────────── // Step 5: Prepare for routing // ────────────────────────────────────────────────────────────────── + let homeLocation: LocationInput? = { + guard request.preferences.useHomeLocation else { return nil } + return request.startLocation + }() + // NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles // allowRepeatCities internally, which allows it to pick the optimal game // per city for route feasibility (e.g., pick July 29 Anaheim instead of @@ -232,10 +237,13 @@ final class ScenarioDPlanner: ScenarioPlanner { for (index, routeGames) in validRoutes.enumerated() { // Build stops for this route - let stops = buildStops(from: routeGames, stadiums: request.stadiums) - + var stops = buildStops(from: routeGames, stadiums: request.stadiums) guard !stops.isEmpty else { continue } + if let homeLocation { + stops = buildStopsWithHomeEndpoints(home: homeLocation, gameStops: stops) + } + // Calculate travel segments using shared ItineraryBuilder guard let itinerary = ItineraryBuilder.build( stops: stops, @@ -401,6 +409,45 @@ final class ScenarioDPlanner: ScenarioPlanner { ) } + /// Wraps game stops with optional home start/end waypoints. + private func buildStopsWithHomeEndpoints( + home: LocationInput, + gameStops: [ItineraryStop] + ) -> [ItineraryStop] { + guard !gameStops.isEmpty else { return [] } + + let calendar = Calendar.current + let firstGameDay = gameStops.first?.arrivalDate ?? Date() + let startDay = calendar.date(byAdding: .day, value: -1, to: firstGameDay) ?? firstGameDay + + let startStop = ItineraryStop( + city: home.name, + state: "", + coordinate: home.coordinate, + games: [], + arrivalDate: startDay, + departureDate: startDay, + location: home, + firstGameStart: nil + ) + + let lastGameDay = gameStops.last?.departureDate ?? firstGameDay + let endDay = calendar.date(byAdding: .day, value: 1, to: lastGameDay) ?? lastGameDay + + let endStop = ItineraryStop( + city: home.name, + state: "", + coordinate: home.coordinate, + games: [], + arrivalDate: endDay, + departureDate: endDay, + location: home, + firstGameStart: nil + ) + + return [startStop] + gameStops + [endStop] + } + // MARK: - Route Deduplication /// Removes duplicate routes (routes with identical game IDs). diff --git a/SportsTime/Planning/Engine/ScenarioEPlanner.swift b/SportsTime/Planning/Engine/ScenarioEPlanner.swift index 897fcba..d4ef39e 100644 --- a/SportsTime/Planning/Engine/ScenarioEPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioEPlanner.swift @@ -16,7 +16,7 @@ // 2. Generate all N-day windows (N = selectedTeamIds.count * 2) // 3. Filter to windows where each selected team has at least 1 home game // 4. Cap at 50 windows (sample if more exist) -// 5. For each valid window, find routes using GameDAGRouter with team games as anchors +// 5. For each valid window, find routes using anchor strategies + fallback search // 6. Rank by shortest duration + minimal miles // 7. Return top 10 results // @@ -168,9 +168,8 @@ final class ScenarioEPlanner: ScenarioPlanner { for (windowIndex, window) in windowsToEvaluate.enumerated() { // Collect games in this window - // Use one home game per team as anchors (the best one for route efficiency) - var gamesInWindow: [Game] = [] - var anchorGameIds = Set() + var gamesByTeamInWindow: [String: [Game]] = [:] + var hasAllTeamsInWindow = true for teamId in selectedTeamIds { guard let teamGames = homeGamesByTeam[teamId] else { continue } @@ -181,32 +180,68 @@ final class ScenarioEPlanner: ScenarioPlanner { if teamGamesInWindow.isEmpty { // Window doesn't have a game for this team - skip this window // This shouldn't happen since we pre-filtered windows - continue + hasAllTeamsInWindow = false + break } - // Add all games to the pool - gamesInWindow.append(contentsOf: teamGamesInWindow) - - // Mark the earliest game as anchor (must visit this team) - if let earliestGame = teamGamesInWindow.sorted(by: { $0.startTime < $1.startTime }).first { - anchorGameIds.insert(earliestGame.id) - } + gamesByTeamInWindow[teamId] = teamGamesInWindow } - // Skip if we don't have anchors for all teams - guard anchorGameIds.count == selectedTeamIds.count else { continue } + guard hasAllTeamsInWindow else { continue } // Remove duplicate games (same game could be added multiple times if team plays multiple home games) + let gamesInWindow = gamesByTeamInWindow.values.flatMap { $0 } let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime } + guard !uniqueGames.isEmpty else { continue } - // Find routes using GameDAGRouter with anchor games - let validRoutes = GameDAGRouter.findAllSensibleRoutes( + // Primary pass: earliest anchor set for each team. + let earliestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in + games.min(by: { $0.startTime < $1.startTime })?.id + }) + + var candidateRoutes = GameDAGRouter.findAllSensibleRoutes( from: uniqueGames, stadiums: request.stadiums, - anchorGameIds: anchorGameIds, + anchorGameIds: earliestAnchorIds, allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops ) + var validRoutes = candidateRoutes.filter { route in + routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds) + } + + // Fallback pass: avoid over-constraining to earliest anchors. + if validRoutes.isEmpty { + let latestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in + games.max(by: { $0.startTime < $1.startTime })?.id + }) + + if latestAnchorIds != earliestAnchorIds { + let latestAnchorRoutes = GameDAGRouter.findAllSensibleRoutes( + from: uniqueGames, + stadiums: request.stadiums, + anchorGameIds: latestAnchorIds, + allowRepeatCities: request.preferences.allowRepeatCities, + stopBuilder: buildStops + ) + candidateRoutes.append(contentsOf: latestAnchorRoutes) + } + + let noAnchorRoutes = GameDAGRouter.findAllSensibleRoutes( + from: uniqueGames, + stadiums: request.stadiums, + allowRepeatCities: request.preferences.allowRepeatCities, + stopBuilder: buildStops + ) + candidateRoutes.append(contentsOf: noAnchorRoutes) + + candidateRoutes = deduplicateRoutes(candidateRoutes) + validRoutes = candidateRoutes.filter { route in + routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds) + } + } + + validRoutes = deduplicateRoutes(validRoutes) // Build itineraries for valid routes for routeGames in validRoutes { @@ -228,12 +263,7 @@ final class ScenarioEPlanner: ScenarioPlanner { dateFormatter.dateFormat = "MMM d" let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))" - let teamsVisited = routeGames.compactMap { game -> String? in - if anchorGameIds.contains(game.id) { - return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId - } - return nil - }.joined(separator: ", ") + let teamsVisited = orderedTeamLabels(for: routeGames, teams: request.teams) let cities = stops.map { $0.city }.joined(separator: " -> ") @@ -273,7 +303,7 @@ final class ScenarioEPlanner: ScenarioPlanner { ) } - // Deduplicate options (same stops in same order) + // Deduplicate options (same stop-day-game structure) let uniqueOptions = deduplicateOptions(allItineraryOptions) // Sort by: shortest duration first, then fewest miles @@ -460,14 +490,19 @@ final class ScenarioEPlanner: ScenarioPlanner { // MARK: - Deduplication - /// Removes duplicate itinerary options (same stops in same order). + /// Removes duplicate itinerary options (same stop-day-game structure). private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] { var seen = Set() var unique: [ItineraryOption] = [] + let calendar = Calendar.current for option in options { - // Create key from stop cities in order - let key = option.stops.map { $0.city }.joined(separator: "-") + // Key by stop city + day + game IDs to avoid collapsing distinct itineraries. + let key = option.stops.map { stop in + let day = Int(calendar.startOfDay(for: stop.arrivalDate).timeIntervalSince1970) + let gameKey = stop.games.sorted().joined(separator: ",") + return "\(stop.city)|\(day)|\(gameKey)" + }.joined(separator: "->") if !seen.contains(key) { seen.insert(key) unique.append(option) @@ -477,6 +512,43 @@ final class ScenarioEPlanner: ScenarioPlanner { return unique } + /// Removes duplicate game routes (same game IDs in any order). + private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] { + var seen = Set() + var unique: [[Game]] = [] + + for route in routes { + let key = route.map { $0.id }.sorted().joined(separator: "-") + if !seen.contains(key) { + seen.insert(key) + unique.append(route) + } + } + + return unique + } + + /// Returns true if a route includes at least one home game for each selected team. + private func routeCoversAllSelectedTeams(_ route: [Game], selectedTeamIds: Set) -> Bool { + let homeTeamsInRoute = Set(route.map { $0.homeTeamId }) + return selectedTeamIds.isSubset(of: homeTeamsInRoute) + } + + /// Team labels in first-visit order for itinerary rationale text. + private func orderedTeamLabels(for route: [Game], teams: [String: Team]) -> String { + var seen = Set() + var labels: [String] = [] + + for game in route { + let teamId = game.homeTeamId + guard !seen.contains(teamId) else { continue } + seen.insert(teamId) + labels.append(teams[teamId]?.abbreviation ?? teamId) + } + + return labels.joined(separator: ", ") + } + // MARK: - Trip Duration Calculation /// Calculates trip duration in days for an itinerary option. diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 72d5b90..fae5839 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -58,8 +58,8 @@ enum TravelEstimator { let distanceMiles = calculateDistanceMiles(from: from, to: to) let drivingHours = distanceMiles / averageSpeedMph - // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) - // This allows multi-day cross-country segments like Chicago → Anaheim + // Maximum allowed: 5 days of driving as a conservative hard cap. + // This allows multi-day cross-country segments like Chicago → Anaheim. let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil @@ -103,8 +103,8 @@ enum TravelEstimator { let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let drivingHours = distanceMiles / averageSpeedMph - // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) - // This allows multi-day cross-country segments like Chicago → Anaheim + // Maximum allowed: 5 days of driving as a conservative hard cap. + // This allows multi-day cross-country segments like Chicago → Anaheim. let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil diff --git a/SportsTimeTests/Domain/TravelInfoTests.swift b/SportsTimeTests/Domain/TravelInfoTests.swift new file mode 100644 index 0000000..dd5881e --- /dev/null +++ b/SportsTimeTests/Domain/TravelInfoTests.swift @@ -0,0 +1,51 @@ +// +// TravelInfoTests.swift +// SportsTimeTests +// +// Tests for canonical TravelInfo construction and city matching. +// + +import Testing +@testable import SportsTime + +@Suite("TravelInfo") +struct TravelInfoTests { + + private func makeSegment(from: String, to: String) -> TravelSegment { + TravelSegment( + fromLocation: LocationInput(name: from), + toLocation: LocationInput(name: to), + travelMode: .drive, + distanceMeters: 120_000, + durationSeconds: 7_200 + ) + } + + @Test("init(segment:) derives canonical city pair and metrics") + func initFromSegment() { + let segment = makeSegment(from: "Detroit", to: "Chicago") + let info = TravelInfo(segment: segment, segmentIndex: 2) + + #expect(info.fromCity == "Detroit") + #expect(info.toCity == "Chicago") + #expect(info.segmentIndex == 2) + #expect(info.distanceMeters == segment.distanceMeters) + #expect(info.durationSeconds == segment.durationSeconds) + } + + @Test("normalizeCityName trims and lowercases") + func normalizeCityName() { + #expect(TravelInfo.normalizeCityName(" New York ") == "new york") + } + + @Test("matches(segment:) uses normalized city comparison") + func matchesSegment() { + let segment = makeSegment(from: "Seattle", to: "Portland") + let info = TravelInfo(fromCity: " seattle ", toCity: "PORTLAND ") + + #expect(info.matches(segment: segment)) + #expect(info.matches(from: "SEATTLE", to: "portland")) + #expect(!info.matches(from: "Seattle", to: "San Francisco")) + } +} + diff --git a/SportsTimeTests/Planning/GameDAGRouterTests.swift b/SportsTimeTests/Planning/GameDAGRouterTests.swift index d328c89..3ac35c9 100644 --- a/SportsTimeTests/Planning/GameDAGRouterTests.swift +++ b/SportsTimeTests/Planning/GameDAGRouterTests.swift @@ -353,6 +353,36 @@ struct GameDAGRouterTests { #expect(!combinedRoutes.isEmpty, "NYC to Chicago over 2 days should be feasible") } + @Test("findRoutes: anchor routes can span gaps larger than 5 days") + func findRoutes_anchorRoutesAllowLongDateGaps() { + let today = calendar.startOfDay(for: Date()) + let day0 = today + let day1 = calendar.date(byAdding: .day, value: 1, to: today)! + let day8 = calendar.date(byAdding: .day, value: 8, to: today)! + + let sharedStadium = makeStadium(city: "New York", coord: nycCoord) + let bridgeStadium = makeStadium(city: "Boston", coord: bostonCoord) + + let anchorStart = makeGame(stadiumId: sharedStadium.id, date: day0) + let bridgeGame = makeGame(stadiumId: bridgeStadium.id, date: day1) + let anchorEnd = makeGame(stadiumId: sharedStadium.id, date: day8) + + let routes = GameDAGRouter.findRoutes( + games: [anchorStart, bridgeGame, anchorEnd], + stadiums: [sharedStadium.id: sharedStadium, bridgeStadium.id: bridgeStadium], + constraints: constraints, + anchorGameIds: [anchorStart.id, anchorEnd.id] + ) + + #expect(!routes.isEmpty, "Expected a route that includes both anchors across an 8-day gap") + + for route in routes { + let ids = Set(route.map { $0.id }) + #expect(ids.contains(anchorStart.id), "Route should include start anchor") + #expect(ids.contains(anchorEnd.id), "Route should include end anchor") + } + } + // MARK: - Property Tests @Test("Property: route count never exceeds maxOptions (75)") diff --git a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift index dc57ad7..314eb7a 100644 --- a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift @@ -220,6 +220,55 @@ struct ScenarioAPlannerTests { #expect(failure.reason == .noGamesInRange) } + @Test("plan: multiple must-stop cities are required without excluding other route games") + func plan_multipleMustStops_requireCoverageWithoutExclusiveFiltering() { + let startDate = Date() + let endDate = startDate.addingTimeInterval(86400 * 10) + + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)) + + let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 1)) + let bostonGame = makeGame(id: "boston-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3)) + let phillyGame = makeGame(id: "philly-game", stadiumId: "philly", dateTime: startDate.addingTimeInterval(86400 * 5)) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + mustStopLocations: [ + LocationInput(name: "New York", coordinate: nycCoord), + LocationInput(name: "Boston", coordinate: bostonCoord) + ], + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [nycGame, bostonGame, phillyGame], + teams: [:], + stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success with two feasible must-stop cities") + return + } + + #expect(!options.isEmpty) + for option in options { + let gameIds = Set(option.stops.flatMap { $0.games }) + #expect(gameIds.contains("nyc-game"), "Each option should satisfy New York must-stop") + #expect(gameIds.contains("boston-game"), "Each option should satisfy Boston must-stop") + } + } + // MARK: - Specification Tests: Successful Planning @Test("plan: single game in range returns success with one option") diff --git a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift index 31e7b56..d3f2294 100644 --- a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift @@ -297,6 +297,49 @@ struct ScenarioBPlannerTests { // May also fail if no valid date ranges, which is acceptable } + @Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation") + func plan_explicitDateRange_selectedGameOutsideRange_returnsDateRangeViolation() { + let baseDate = Date() + let rangeStart = baseDate + let rangeEnd = baseDate.addingTimeInterval(86400 * 3) + let outOfRangeDate = baseDate.addingTimeInterval(86400 * 10) + + let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord) + let selectedGame = makeGame(id: "outside-anchor", stadiumId: "stadium1", dateTime: outOfRangeDate) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + mustSeeGameIds: ["outside-anchor"], + startDate: rangeStart, + endDate: rangeEnd, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 1 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [selectedGame], + teams: [:], + stadiums: ["stadium1": stadium] + ) + + let result = planner.plan(request: request) + + guard case .failure(let failure) = result else { + Issue.record("Expected date range violation when selected game is outside explicit range") + return + } + + guard case .dateRangeViolation(let violatingGames) = failure.reason else { + Issue.record("Expected .dateRangeViolation, got \(failure.reason)") + return + } + + #expect(Set(violatingGames.map { $0.id }) == ["outside-anchor"]) + } + // MARK: - Specification Tests: Arrival Time Validation @Test("plan: uses arrivalBeforeGameStart validator") diff --git a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift index 5329931..96da2da 100644 --- a/SportsTimeTests/Planning/ScenarioCPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioCPlannerTests.swift @@ -197,6 +197,47 @@ struct ScenarioCPlannerTests { #expect(failure.reason == .noGamesInRange) } + @Test("plan: city names with state suffixes match stadium city names") + func plan_cityNamesWithStateSuffixes_matchStadiumCities() { + let baseDate = Date() + let endDate = baseDate.addingTimeInterval(86400 * 10) + + let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord) + let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord) + + let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + + let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: baseDate.addingTimeInterval(86400 * 1)) + let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: baseDate.addingTimeInterval(86400 * 4)) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: startLocation, + endLocation: endLocation, + sports: [.mlb], + startDate: baseDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [chicagoGame, nycGame], + teams: [:], + stadiums: ["chicago": chicagoStadium, "nyc": nycStadium] + ) + + let result = planner.plan(request: request) + + guard case .success = result else { + Issue.record("Expected success with city/state location labels matching plain stadium cities") + return + } + } + // MARK: - Specification Tests: Directional Filtering @Test("plan: directional filtering includes stadiums toward destination") diff --git a/SportsTimeTests/Planning/ScenarioDPlannerTests.swift b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift index 7a83b80..3a1e6bf 100644 --- a/SportsTimeTests/Planning/ScenarioDPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift @@ -272,6 +272,63 @@ struct ScenarioDPlannerTests { #expect(!options.isEmpty) } + @Test("plan: useHomeLocation with startLocation adds home start and end stops") + func plan_useHomeLocationWithStartLocation_addsHomeEndpoints() { + let startDate = Date() + let endDate = startDate.addingTimeInterval(86400 * 10) + + let homeCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) // Denver + let homeLocation = LocationInput(name: "Denver", coordinate: homeCoord) + + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + let game = Game( + id: "team-game", + homeTeamId: "red-sox", + awayTeamId: "yankees", + stadiumId: "boston", + dateTime: startDate.addingTimeInterval(86400 * 3), + sport: .mlb, + season: "2026", + isPlayoff: false + ) + + let prefs = TripPreferences( + planningMode: .followTeam, + startLocation: homeLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2, + followTeamId: "yankees", + useHomeLocation: true + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [game], + teams: [:], + stadiums: ["boston": bostonStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success with home endpoint enabled") + return + } + + #expect(!options.isEmpty) + + for option in options { + #expect(option.stops.first?.city == "Denver") + #expect(option.stops.last?.city == "Denver") + #expect(option.stops.first?.games.isEmpty == true) + #expect(option.stops.last?.games.isEmpty == true) + } + } + // MARK: - Invariant Tests @Test("Invariant: all returned games have team as home or away") diff --git a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift index 3cc66ae..b1944c2 100644 --- a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift @@ -406,6 +406,146 @@ struct ScenarioEPlannerTests { } } + @Test("plan: falls back when earliest per-team anchors are infeasible") + func plan_fallbackWhenEarliestAnchorsInfeasible() { + let calendar = Calendar.current + let baseDate = calendar.startOfDay(for: Date()) + + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let chicagoStadium = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord) + + // Team A has one early game in NYC. + let teamAGame = makeGame( + id: "team-a-day1", + homeTeamId: "teamA", + awayTeamId: "opp", + stadiumId: "nyc", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))! + ) + + // Team B has an early game (day 2, infeasible from NYC with 1 driver), + // and a later game (day 4, feasible and should be selected by fallback). + let teamBEarly = makeGame( + id: "team-b-day2", + homeTeamId: "teamB", + awayTeamId: "opp", + stadiumId: "chi", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 2))! + ) + let teamBLate = makeGame( + id: "team-b-day4", + homeTeamId: "teamB", + awayTeamId: "opp", + stadiumId: "chi", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))! + ) + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + startDate: baseDate, + endDate: baseDate.addingTimeInterval(86400 * 30), + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 1, + selectedTeamIds: ["teamA", "teamB"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [teamAGame, teamBEarly, teamBLate], + teams: [ + "teamA": makeTeam(id: "teamA", name: "Team A"), + "teamB": makeTeam(id: "teamB", name: "Team B") + ], + stadiums: ["nyc": nycStadium, "chi": chicagoStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected fallback success when earliest anchor combo is infeasible") + return + } + + #expect(!options.isEmpty) + + let optionGameIds = options.map { Set($0.stops.flatMap { $0.games }) } + #expect(optionGameIds.contains { $0.contains("team-a-day1") && $0.contains("team-b-day4") }, + "Expected at least one route that uses the later feasible Team B game") + } + + @Test("plan: keeps date-distinct options even when city order is identical") + func plan_keepsDistinctGameSetsWithSameCityOrder() { + let calendar = Calendar.current + let baseDate = calendar.startOfDay(for: Date()) + + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + + let teamAFirst = makeGame( + id: "team-a-day1", + homeTeamId: "teamA", + awayTeamId: "opp", + stadiumId: "nyc", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))! + ) + let teamBFirst = makeGame( + id: "team-b-day4", + homeTeamId: "teamB", + awayTeamId: "opp", + stadiumId: "boston", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))! + ) + let teamASecond = makeGame( + id: "team-a-day10", + homeTeamId: "teamA", + awayTeamId: "opp", + stadiumId: "nyc", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))! + ) + let teamBSecond = makeGame( + id: "team-b-day13", + homeTeamId: "teamB", + awayTeamId: "opp", + stadiumId: "boston", + dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))! + ) + + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + startDate: baseDate, + endDate: baseDate.addingTimeInterval(86400 * 30), + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2, + selectedTeamIds: ["teamA", "teamB"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [teamAFirst, teamBFirst, teamASecond, teamBSecond], + teams: [ + "teamA": makeTeam(id: "teamA", name: "Team A"), + "teamB": makeTeam(id: "teamB", name: "Team B") + ], + stadiums: ["nyc": nycStadium, "boston": bostonStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success for repeated city-order windows") + return + } + + let uniqueGameSets = Set(options.map { option in + option.stops.flatMap { $0.games }.sorted().joined(separator: "-") + }) + #expect(uniqueGameSets.count >= 2, "Expected distinct date/game combinations to survive deduplication") + } + @Test("plan: routes sorted by duration ascending") func plan_routesSortedByDurationAscending() { let baseDate = Date()