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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,51 @@ struct TravelInfo: Codable, Hashable {
|
|||||||
var distanceMeters: Double?
|
var distanceMeters: Double?
|
||||||
var durationSeconds: 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 {
|
var formattedDistance: String {
|
||||||
guard let meters = distanceMeters else { return "" }
|
guard let meters = distanceMeters else { return "" }
|
||||||
let miles = meters / 1609.34
|
let miles = meters / 1609.34
|
||||||
|
|||||||
@@ -831,21 +831,23 @@ enum ItineraryReorderingLogic {
|
|||||||
in travelValidRanges: [String: ClosedRange<Int>],
|
in travelValidRanges: [String: ClosedRange<Int>],
|
||||||
model: ItineraryItem? = nil
|
model: ItineraryItem? = nil
|
||||||
) -> String {
|
) -> 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) }
|
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 {
|
if matchingKeys.count == 1, let key = matchingKeys.first {
|
||||||
return key
|
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
|
// Fallback: return first match or construct without index
|
||||||
return matchingKeys.first ?? "travel:\(suffix)"
|
return matchingKeys.first ?? "travel:\(suffix)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,6 +321,9 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
|
|
||||||
/// All itinerary items (needed to build constraints)
|
/// All itinerary items (needed to build constraints)
|
||||||
private var allItineraryItems: [ItineraryItem] = []
|
private var allItineraryItems: [ItineraryItem] = []
|
||||||
|
|
||||||
|
/// Canonical trip travel segment index keyed by TravelSegment UUID.
|
||||||
|
private var travelSegmentIndices: [UUID: Int] = [:]
|
||||||
|
|
||||||
/// Trip day count for constraints
|
/// Trip day count for constraints
|
||||||
private var tripDayCount: Int = 0
|
private var tripDayCount: Int = 0
|
||||||
@@ -472,10 +475,12 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
func reloadData(
|
func reloadData(
|
||||||
days: [ItineraryDayData],
|
days: [ItineraryDayData],
|
||||||
travelValidRanges: [String: ClosedRange<Int>],
|
travelValidRanges: [String: ClosedRange<Int>],
|
||||||
itineraryItems: [ItineraryItem] = []
|
itineraryItems: [ItineraryItem] = [],
|
||||||
|
travelSegmentIndices: [UUID: Int] = [:]
|
||||||
) {
|
) {
|
||||||
self.travelValidRanges = travelValidRanges
|
self.travelValidRanges = travelValidRanges
|
||||||
self.allItineraryItems = itineraryItems
|
self.allItineraryItems = itineraryItems
|
||||||
|
self.travelSegmentIndices = travelSegmentIndices
|
||||||
self.tripDayCount = days.count
|
self.tripDayCount = days.count
|
||||||
|
|
||||||
// Rebuild constraints with new data
|
// Rebuild constraints with new data
|
||||||
@@ -571,12 +576,35 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// Calculates invalid zones for a travel segment drag.
|
/// Calculates invalid zones for a travel segment drag.
|
||||||
/// Delegates to pure function and applies results to instance state.
|
/// Delegates to pure function and applies results to instance state.
|
||||||
private func calculateTravelDragZones(segment: TravelSegment) {
|
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(
|
let zones = ItineraryReorderingLogic.calculateTravelDragZones(
|
||||||
segment: segment,
|
segment: segment,
|
||||||
|
sourceRow: sourceRow,
|
||||||
flatItems: flatItems,
|
flatItems: flatItems,
|
||||||
travelValidRanges: travelValidRanges,
|
travelValidRanges: travelValidRanges,
|
||||||
constraints: constraints,
|
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
|
invalidRowIndices = zones.invalidRowIndices
|
||||||
validDropRows = zones.validDropRows
|
validDropRows = zones.validDropRows
|
||||||
@@ -586,7 +614,20 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// Calculates invalid zones for a custom item drag.
|
/// Calculates invalid zones for a custom item drag.
|
||||||
/// Delegates to pure function and applies results to instance state.
|
/// Delegates to pure function and applies results to instance state.
|
||||||
private func calculateCustomItemDragZones(item: ItineraryItem) {
|
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
|
invalidRowIndices = zones.invalidRowIndices
|
||||||
validDropRows = zones.validDropRows
|
validDropRows = zones.validDropRows
|
||||||
barrierGameIds = zones.barrierGameIds
|
barrierGameIds = zones.barrierGameIds
|
||||||
@@ -597,23 +638,31 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// Searches through allItineraryItems to find a matching travel item.
|
/// Searches through allItineraryItems to find a matching travel item.
|
||||||
/// Prefers matching by segmentIndex for disambiguation of repeat city pairs.
|
/// Prefers matching by segmentIndex for disambiguation of repeat city pairs.
|
||||||
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
||||||
let fromLower = segment.fromLocation.name.lowercased()
|
if let segIdx = travelSegmentIndices[segment.id] {
|
||||||
let toLower = segment.toLocation.name.lowercased()
|
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
|
// Find all matching travel items by city pair
|
||||||
let matches = allItineraryItems.filter { item in
|
let matches = allItineraryItems.filter { item in
|
||||||
guard case .travel(let info) = item.kind else { return false }
|
guard case .travel(let info) = item.kind else { return false }
|
||||||
return info.fromCity.lowercased() == fromLower
|
return info.matches(segment: segment)
|
||||||
&& info.toCity.lowercased() == toLower
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If only one match, return it
|
// If only one match, return it
|
||||||
if matches.count <= 1 { return matches.first }
|
if matches.count <= 1 { return matches.first }
|
||||||
|
|
||||||
// Multiple matches (repeat city pair) — try to match by segment UUID identity
|
if let segIdx = travelSegmentIndices[segment.id] {
|
||||||
// The segment.id is a UUID that identifies the specific TravelSegment instance
|
if let indexed = matches.first(where: { $0.travelInfo?.segmentIndex == segIdx }) {
|
||||||
// We match through the allItineraryItems which have segmentIndex set
|
return indexed
|
||||||
return matches.first ?? nil
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.first(where: { $0.travelInfo?.segmentIndex != nil }) ?? matches.first
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies visual feedback during drag.
|
/// 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)
|
// Travel is positioned within a day using sortOrder (can be before/after games)
|
||||||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||||||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||||||
let segIdx = findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0
|
let segIdx = travelSegmentIndices[segment.id]
|
||||||
let travelId = "travel:\(segIdx):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
?? 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)
|
onTravelMoved?(travelId, destinationDay, sortOrder)
|
||||||
|
|
||||||
case .customItem(let customItem):
|
case .customItem(let customItem):
|
||||||
@@ -852,13 +905,14 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
constraints: constraints,
|
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
|
makeTravelItem: { [weak self] segment in
|
||||||
ItineraryItem(
|
let segIdx = self?.travelSegmentIndices[segment.id]
|
||||||
|
return ItineraryItem(
|
||||||
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
||||||
day: 1,
|
day: 1,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
kind: .travel(TravelInfo(
|
kind: .travel(TravelInfo(
|
||||||
fromCity: segment.fromLocation.name,
|
segment: segment,
|
||||||
toCity: segment.toLocation.name,
|
segmentIndex: segIdx,
|
||||||
distanceMeters: segment.distanceMeters,
|
distanceMeters: segment.distanceMeters,
|
||||||
durationSeconds: segment.durationSeconds
|
durationSeconds: segment.durationSeconds
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -82,8 +82,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
controller.setTableHeader(hostingController.view)
|
controller.setTableHeader(hostingController.view)
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
|
||||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
controller.reloadData(
|
||||||
|
days: days,
|
||||||
|
travelValidRanges: validRanges,
|
||||||
|
itineraryItems: allItemsForConstraints,
|
||||||
|
travelSegmentIndices: travelSegmentIndices
|
||||||
|
)
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -100,15 +105,21 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
// This avoids recreating the view hierarchy and prevents infinite loops
|
// This avoids recreating the view hierarchy and prevents infinite loops
|
||||||
context.coordinator.headerHostingController?.rootView = headerContent
|
context.coordinator.headerHostingController?.rootView = headerContent
|
||||||
|
|
||||||
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
|
||||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
controller.reloadData(
|
||||||
|
days: days,
|
||||||
|
travelValidRanges: validRanges,
|
||||||
|
itineraryItems: allItemsForConstraints,
|
||||||
|
travelSegmentIndices: travelSegmentIndices
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Build Itinerary Data
|
// MARK: - Build Itinerary Data
|
||||||
|
|
||||||
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
|
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem], [UUID: Int]) {
|
||||||
let tripDays = calculateTripDays()
|
let tripDays = calculateTripDays()
|
||||||
var travelValidRanges: [String: ClosedRange<Int>] = [:]
|
var travelValidRanges: [String: ClosedRange<Int>] = [:]
|
||||||
|
let travelSegmentIndices = Dictionary(uniqueKeysWithValues: trip.travelSegments.enumerated().map { ($1.id, $0) })
|
||||||
|
|
||||||
// Build game items from RichGame data for constraint validation
|
// Build game items from RichGame data for constraint validation
|
||||||
var gameItems: [ItineraryItem] = []
|
var gameItems: [ItineraryItem] = []
|
||||||
@@ -183,13 +194,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
day: placement.day,
|
day: placement.day,
|
||||||
sortOrder: placement.sortOrder,
|
sortOrder: placement.sortOrder,
|
||||||
kind: .travel(
|
kind: .travel(
|
||||||
TravelInfo(
|
TravelInfo(segment: segment, segmentIndex: segmentIndex)
|
||||||
fromCity: fromCity,
|
|
||||||
toCity: toCity,
|
|
||||||
segmentIndex: segmentIndex,
|
|
||||||
distanceMeters: segment.distanceMeters,
|
|
||||||
durationSeconds: segment.durationSeconds
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
travelItems.append(travelItem)
|
travelItems.append(travelItem)
|
||||||
@@ -218,20 +223,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
.filter { $0.day == dayNum }
|
.filter { $0.day == dayNum }
|
||||||
.sorted { $0.sortOrder < $1.sortOrder }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for travel in travelsForDay {
|
for travel in travelsForDay {
|
||||||
// Find the segment matching this travel by segment index (preferred) or city pair (legacy)
|
|
||||||
if let info = travel.travelInfo {
|
if let info = travel.travelInfo {
|
||||||
let seg: TravelSegment?
|
guard let idx = info.segmentIndex,
|
||||||
if let idx = info.segmentIndex, idx < trip.travelSegments.count {
|
idx >= 0,
|
||||||
seg = trip.travelSegments[idx]
|
idx < trip.travelSegments.count else { continue }
|
||||||
} else {
|
let seg = trip.travelSegments[idx]
|
||||||
seg = trip.travelSegments.first(where: {
|
guard info.matches(segment: seg) else { continue }
|
||||||
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
|
rows.append(.travel(seg, dayNumber: dayNum))
|
||||||
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if let seg {
|
|
||||||
rows.append(.travel(seg, dayNumber: dayNum))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +239,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
switch r {
|
switch r {
|
||||||
case .customItem(let it): return it.sortOrder
|
case .customItem(let it): return it.sortOrder
|
||||||
case .travel(let seg, _):
|
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)
|
let id = stableTravelAnchorId(seg, at: segIdx)
|
||||||
return (travelOverrides[id]?.sortOrder)
|
return (travelOverrides[id]?.sortOrder)
|
||||||
?? (travelItems.first(where: { ti in
|
?? (travelItems.first(where: { ti in
|
||||||
@@ -265,8 +263,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
)
|
)
|
||||||
days.append(dayData)
|
days.append(dayData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (days, travelValidRanges, gameItems + itineraryItems + travelItems)
|
return (days, travelValidRanges, gameItems + itineraryItems + travelItems, travelSegmentIndices)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
@@ -294,7 +292,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
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 {
|
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
|
||||||
|
|||||||
@@ -797,8 +797,8 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
|
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
|
||||||
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
||||||
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
||||||
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
||||||
return "travel:\(index):\(from)->\(to)"
|
return "travel:\(index):\(from)->\(to)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +975,17 @@ struct TripDetailView: View {
|
|||||||
return index
|
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
|
// MARK: - Map Helpers
|
||||||
|
|
||||||
private func fetchDrivingRoutes() async {
|
private func fetchDrivingRoutes() async {
|
||||||
@@ -1259,8 +1270,7 @@ struct TripDetailView: View {
|
|||||||
day: day,
|
day: day,
|
||||||
sortOrder: sortOrder,
|
sortOrder: sortOrder,
|
||||||
kind: .travel(TravelInfo(
|
kind: .travel(TravelInfo(
|
||||||
fromCity: segment.fromLocation.name,
|
segment: segment,
|
||||||
toCity: segment.toLocation.name,
|
|
||||||
segmentIndex: segmentIndex,
|
segmentIndex: segmentIndex,
|
||||||
distanceMeters: segment.distanceMeters,
|
distanceMeters: segment.distanceMeters,
|
||||||
durationSeconds: segment.durationSeconds
|
durationSeconds: segment.durationSeconds
|
||||||
@@ -1387,22 +1397,32 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
for item in items where item.isTravel {
|
for item in items where item.isTravel {
|
||||||
guard let travelInfo = item.travelInfo else { continue }
|
guard let travelInfo = item.travelInfo else { continue }
|
||||||
let from = travelInfo.fromCity.lowercased()
|
if let segIdx = travelInfo.segmentIndex,
|
||||||
let to = travelInfo.toCity.lowercased()
|
segIdx >= 0,
|
||||||
|
segIdx < trip.travelSegments.count {
|
||||||
if let segIdx = travelInfo.segmentIndex {
|
let segment = trip.travelSegments[segIdx]
|
||||||
// New format with segment index
|
if !travelInfo.matches(segment: segment) {
|
||||||
let travelId = "travel:\(segIdx):\(from)->\(to)"
|
print("⚠️ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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
|
travelOverrides = overrides
|
||||||
@@ -1478,8 +1498,11 @@ struct TripDetailView: View {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Check if this is a travel segment being dropped
|
// Check if this is a travel segment being dropped
|
||||||
if droppedId.hasPrefix("travel:") {
|
if droppedId.hasPrefix("travel:") {
|
||||||
|
guard let canonicalTravelId = self.canonicalTravelAnchorId(from: droppedId) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Validate travel is within valid bounds (day-level)
|
// 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 {
|
guard validRange.contains(dayNumber) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1499,12 +1522,12 @@ struct TripDetailView: View {
|
|||||||
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
|
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
|
self.travelOverrides[canonicalTravelId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist to CloudKit as a travel ItineraryItem
|
// Persist to CloudKit as a travel ItineraryItem
|
||||||
await self.saveTravelDayOverride(
|
await self.saveTravelDayOverride(
|
||||||
travelAnchorId: droppedId,
|
travelAnchorId: canonicalTravelId,
|
||||||
displayDay: dayNumber,
|
displayDay: dayNumber,
|
||||||
sortOrder: newSortOrder
|
sortOrder: newSortOrder
|
||||||
)
|
)
|
||||||
@@ -1531,46 +1554,40 @@ struct TripDetailView: View {
|
|||||||
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
|
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
|
||||||
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
|
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
|
||||||
|
|
||||||
// Parse travel ID (format: "travel:INDEX:from->to")
|
guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId),
|
||||||
let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId)
|
segmentIndex >= 0,
|
||||||
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
|
segmentIndex < trip.travelSegments.count else {
|
||||||
let colonParts = stripped.components(separatedBy: ":")
|
print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)")
|
||||||
// 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)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fromCity = cityParts[0]
|
let segment = trip.travelSegments[segmentIndex]
|
||||||
let toCity = cityParts[1]
|
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: {
|
if let existingIndex = itineraryItems.firstIndex(where: {
|
||||||
guard $0.isTravel, let info = $0.travelInfo else { return false }
|
guard $0.isTravel, let info = $0.travelInfo else { return false }
|
||||||
// Match by segment index if available
|
if let itemIdx = info.segmentIndex {
|
||||||
if let idx = segmentIndex, let itemIdx = info.segmentIndex {
|
return itemIdx == segmentIndex
|
||||||
return idx == itemIdx
|
|
||||||
}
|
}
|
||||||
// Legacy fallback: match by city pair
|
return info.matches(segment: segment)
|
||||||
return info.fromCity.lowercased() == fromCity && info.toCity.lowercased() == toCity
|
|
||||||
}) {
|
}) {
|
||||||
// Update existing
|
// Update existing
|
||||||
var updated = itineraryItems[existingIndex]
|
var updated = itineraryItems[existingIndex]
|
||||||
updated.day = displayDay
|
updated.day = displayDay
|
||||||
updated.sortOrder = sortOrder
|
updated.sortOrder = sortOrder
|
||||||
updated.modifiedAt = Date()
|
updated.modifiedAt = Date()
|
||||||
|
updated.kind = .travel(canonicalInfo)
|
||||||
itineraryItems[existingIndex] = updated
|
itineraryItems[existingIndex] = updated
|
||||||
await ItineraryItemService.shared.updateItem(updated)
|
await ItineraryItemService.shared.updateItem(updated)
|
||||||
} else {
|
} else {
|
||||||
// Create new travel item
|
// Create new travel item
|
||||||
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity, segmentIndex: segmentIndex)
|
|
||||||
let item = ItineraryItem(
|
let item = ItineraryItem(
|
||||||
tripId: trip.id,
|
tripId: trip.id,
|
||||||
day: displayDay,
|
day: displayDay,
|
||||||
sortOrder: sortOrder,
|
sortOrder: sortOrder,
|
||||||
kind: .travel(travelInfo)
|
kind: .travel(canonicalInfo)
|
||||||
)
|
)
|
||||||
itineraryItems.append(item)
|
itineraryItems.append(item)
|
||||||
do {
|
do {
|
||||||
@@ -1580,6 +1597,9 @@ struct TripDetailView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if canonicalTravelId != travelAnchorId {
|
||||||
|
print("ℹ️ [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)")
|
||||||
|
}
|
||||||
print("✅ [TravelOverrides] Saved to CloudKit")
|
print("✅ [TravelOverrides] Saved to CloudKit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,6 @@ enum GameDAGRouter {
|
|||||||
/// Buffer time after game ends before we can depart (hours)
|
/// Buffer time after game ends before we can depart (hours)
|
||||||
private static let gameEndBufferHours: Double = 3.0
|
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
|
// MARK: - Route Profile
|
||||||
|
|
||||||
/// Captures the key metrics of a route for diversity analysis
|
/// 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
|
// Step 2.5: Calculate effective beam width for this dataset size
|
||||||
let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)
|
let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)
|
||||||
|
|
||||||
// Step 3: Initialize beam with first few days' games as starting points
|
// Step 3: Initialize beam from all games so later-starting valid routes
|
||||||
var beam: [[Game]] = []
|
// (including anchor-driven routes) are not dropped up front.
|
||||||
for dayIndex in sortedDays.prefix(maxDayLookahead) {
|
let initialBeam = sortedGames.map { [$0] }
|
||||||
if let dayGames = buckets[dayIndex] {
|
let beamSeedLimit = max(scaledBeamWidth * 2, 50)
|
||||||
for game in dayGames {
|
|
||||||
beam.append([game])
|
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
|
// Step 4: Expand beam day by day with early termination
|
||||||
@@ -200,13 +212,6 @@ enum GameDAGRouter {
|
|||||||
|
|
||||||
for path in beam {
|
for path in beam {
|
||||||
guard let lastGame = path.last else { continue }
|
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
|
// Try adding each of today's games
|
||||||
for candidate in todaysGames {
|
for candidate in todaysGames {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import CoreLocation
|
|||||||
///
|
///
|
||||||
/// Input:
|
/// Input:
|
||||||
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
/// - 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:
|
/// Output:
|
||||||
/// - Success: Ranked list of itinerary options
|
/// - Success: Ranked list of itinerary options
|
||||||
@@ -30,8 +30,8 @@ import CoreLocation
|
|||||||
/// - No date range → returns .failure with .missingDateRange
|
/// - No date range → returns .failure with .missingDateRange
|
||||||
/// - No games in date range → returns .failure with .noGamesInRange
|
/// - No games in date range → returns .failure with .noGamesInRange
|
||||||
/// - With selectedRegions → only includes games in those regions
|
/// - With selectedRegions → only includes games in those regions
|
||||||
/// - With mustStopLocation → filters to home games in that city
|
/// - With mustStopLocations → route must include at least one game in each must-stop city
|
||||||
/// - Empty games after must-stop filter → .failure with .noGamesInRange
|
/// - Missing games for any must-stop city → .failure with .noGamesInRange
|
||||||
/// - No valid routes from GameDAGRouter → .failure with .noValidRoutes
|
/// - No valid routes from GameDAGRouter → .failure with .noValidRoutes
|
||||||
/// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable
|
/// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable
|
||||||
/// - Success → returns sorted itineraries based on leisureLevel
|
/// - Success → returns sorted itineraries based on leisureLevel
|
||||||
@@ -109,33 +109,36 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
// Step 2b: Filter by must-stop locations (if any)
|
// Step 2b: Filter by must-stop locations (if any)
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
// If user specified a must-stop city, filter to HOME games in that city.
|
// Must-stops are route constraints, not exclusive filters.
|
||||||
// A "home game" means the stadium is in the must-stop city.
|
// Keep all games in range, then require routes to include each must-stop city.
|
||||||
var filteredGames = gamesInRange
|
let requiredMustStops = request.preferences.mustStopLocations.filter { stop in
|
||||||
if let mustStop = request.mustStopLocation {
|
!normalizeCityName(stop.name).isEmpty
|
||||||
let mustStopCity = mustStop.name.lowercased()
|
}
|
||||||
filteredGames = gamesInRange.filter { game in
|
|
||||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
if !requiredMustStops.isEmpty {
|
||||||
let stadiumCity = stadium.city.lowercased()
|
let missingMustStops = requiredMustStops.filter { mustStop in
|
||||||
// Match if either contains the other (handles "Chicago" vs "Chicago, IL")
|
!gamesInRange.contains { game in
|
||||||
return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity)
|
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(
|
return .failure(
|
||||||
PlanningFailure(
|
PlanningFailure(
|
||||||
reason: .noGamesInRange,
|
reason: .noGamesInRange,
|
||||||
violations: [
|
violations: violations
|
||||||
ConstraintViolation(
|
|
||||||
type: .mustStop,
|
|
||||||
description: "No home games found in \(mustStop.name) during selected dates",
|
|
||||||
severity: .error
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let filteredGames = gamesInRange
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
// Step 3: Find ALL geographically sensible route variations
|
// Step 3: Find ALL geographically sensible route variations
|
||||||
@@ -177,6 +180,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
// Deduplicate routes (same game IDs)
|
// Deduplicate routes (same game IDs)
|
||||||
validRoutes = deduplicateRoutes(validRoutes)
|
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)")
|
print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)")
|
||||||
if let firstRoute = validRoutes.first {
|
if let firstRoute = validRoutes.first {
|
||||||
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
|
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
|
||||||
@@ -185,13 +200,16 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if validRoutes.isEmpty {
|
if validRoutes.isEmpty {
|
||||||
|
let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty
|
||||||
return .failure(
|
return .failure(
|
||||||
PlanningFailure(
|
PlanningFailure(
|
||||||
reason: .noValidRoutes,
|
reason: .noValidRoutes,
|
||||||
violations: [
|
violations: [
|
||||||
ConstraintViolation(
|
ConstraintViolation(
|
||||||
type: .geographicSanity,
|
type: noMustStopSatisfyingRoutes ? .mustStop : .geographicSanity,
|
||||||
description: "No geographically sensible route found for games in this date range",
|
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
|
severity: .error
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -406,6 +424,39 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
return unique
|
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
|
// MARK: - Regional Route Finding
|
||||||
|
|
||||||
/// Finds routes by running beam search separately for each geographic region.
|
/// Finds routes by running beam search separately for each geographic region.
|
||||||
|
|||||||
@@ -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)
|
// Step 2: Generate date ranges (sliding window or single range)
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -295,12 +295,42 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
|||||||
cityName: String,
|
cityName: String,
|
||||||
stadiums: [String: Stadium]
|
stadiums: [String: Stadium]
|
||||||
) -> [Stadium] {
|
) -> [Stadium] {
|
||||||
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
|
let normalizedCity = normalizeCityName(cityName)
|
||||||
return stadiums.values.filter { stadium in
|
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.
|
/// Finds stadiums that make forward progress from start to end.
|
||||||
///
|
///
|
||||||
/// A stadium is "directional" if visiting it doesn't significantly increase
|
/// A stadium is "directional" if visiting it doesn't significantly increase
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import CoreLocation
|
|||||||
/// - date_range: Required. The trip dates.
|
/// - date_range: Required. The trip dates.
|
||||||
/// - selectedRegions: Optional. Filter to specific regions.
|
/// - selectedRegions: Optional. Filter to specific regions.
|
||||||
/// - useHomeLocation: Whether to start/end from user's home.
|
/// - 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:
|
/// Output:
|
||||||
/// - Success: Ranked list of itinerary options
|
/// - Success: Ranked list of itinerary options
|
||||||
@@ -173,6 +173,11 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
|||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
// Step 5: Prepare for routing
|
// 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
|
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
|
||||||
// allowRepeatCities internally, which allows it to pick the optimal game
|
// allowRepeatCities internally, which allows it to pick the optimal game
|
||||||
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
|
// 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() {
|
for (index, routeGames) in validRoutes.enumerated() {
|
||||||
// Build stops for this route
|
// 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 }
|
guard !stops.isEmpty else { continue }
|
||||||
|
|
||||||
|
if let homeLocation {
|
||||||
|
stops = buildStopsWithHomeEndpoints(home: homeLocation, gameStops: stops)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate travel segments using shared ItineraryBuilder
|
// Calculate travel segments using shared ItineraryBuilder
|
||||||
guard let itinerary = ItineraryBuilder.build(
|
guard let itinerary = ItineraryBuilder.build(
|
||||||
stops: stops,
|
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
|
// MARK: - Route Deduplication
|
||||||
|
|
||||||
/// Removes duplicate routes (routes with identical game IDs).
|
/// Removes duplicate routes (routes with identical game IDs).
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
||||||
// 3. Filter to windows where each selected team has at least 1 home game
|
// 3. Filter to windows where each selected team has at least 1 home game
|
||||||
// 4. Cap at 50 windows (sample if more exist)
|
// 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
|
// 6. Rank by shortest duration + minimal miles
|
||||||
// 7. Return top 10 results
|
// 7. Return top 10 results
|
||||||
//
|
//
|
||||||
@@ -168,9 +168,8 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
|
|
||||||
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
||||||
// Collect games in this window
|
// Collect games in this window
|
||||||
// Use one home game per team as anchors (the best one for route efficiency)
|
var gamesByTeamInWindow: [String: [Game]] = [:]
|
||||||
var gamesInWindow: [Game] = []
|
var hasAllTeamsInWindow = true
|
||||||
var anchorGameIds = Set<String>()
|
|
||||||
|
|
||||||
for teamId in selectedTeamIds {
|
for teamId in selectedTeamIds {
|
||||||
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
||||||
@@ -181,32 +180,68 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
if teamGamesInWindow.isEmpty {
|
if teamGamesInWindow.isEmpty {
|
||||||
// Window doesn't have a game for this team - skip this window
|
// Window doesn't have a game for this team - skip this window
|
||||||
// This shouldn't happen since we pre-filtered windows
|
// This shouldn't happen since we pre-filtered windows
|
||||||
continue
|
hasAllTeamsInWindow = false
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all games to the pool
|
gamesByTeamInWindow[teamId] = teamGamesInWindow
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we don't have anchors for all teams
|
guard hasAllTeamsInWindow else { continue }
|
||||||
guard anchorGameIds.count == selectedTeamIds.count else { continue }
|
|
||||||
|
|
||||||
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
|
// 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 }
|
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
|
||||||
|
guard !uniqueGames.isEmpty else { continue }
|
||||||
|
|
||||||
// Find routes using GameDAGRouter with anchor games
|
// Primary pass: earliest anchor set for each team.
|
||||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
let earliestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
||||||
|
games.min(by: { $0.startTime < $1.startTime })?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
var candidateRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||||
from: uniqueGames,
|
from: uniqueGames,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: anchorGameIds,
|
anchorGameIds: earliestAnchorIds,
|
||||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
stopBuilder: buildStops
|
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
|
// Build itineraries for valid routes
|
||||||
for routeGames in validRoutes {
|
for routeGames in validRoutes {
|
||||||
@@ -228,12 +263,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
dateFormatter.dateFormat = "MMM d"
|
dateFormatter.dateFormat = "MMM d"
|
||||||
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
||||||
|
|
||||||
let teamsVisited = routeGames.compactMap { game -> String? in
|
let teamsVisited = orderedTeamLabels(for: routeGames, teams: request.teams)
|
||||||
if anchorGameIds.contains(game.id) {
|
|
||||||
return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}.joined(separator: ", ")
|
|
||||||
|
|
||||||
let cities = stops.map { $0.city }.joined(separator: " -> ")
|
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)
|
let uniqueOptions = deduplicateOptions(allItineraryOptions)
|
||||||
|
|
||||||
// Sort by: shortest duration first, then fewest miles
|
// Sort by: shortest duration first, then fewest miles
|
||||||
@@ -460,14 +490,19 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
|
|
||||||
// MARK: - Deduplication
|
// 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] {
|
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var unique: [ItineraryOption] = []
|
var unique: [ItineraryOption] = []
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
// Create key from stop cities in order
|
// Key by stop city + day + game IDs to avoid collapsing distinct itineraries.
|
||||||
let key = option.stops.map { $0.city }.joined(separator: "-")
|
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) {
|
if !seen.contains(key) {
|
||||||
seen.insert(key)
|
seen.insert(key)
|
||||||
unique.append(option)
|
unique.append(option)
|
||||||
@@ -477,6 +512,43 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
|||||||
return unique
|
return unique
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes duplicate game routes (same game IDs in any order).
|
||||||
|
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
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<String>) -> 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<String>()
|
||||||
|
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
|
// MARK: - Trip Duration Calculation
|
||||||
|
|
||||||
/// Calculates trip duration in days for an itinerary option.
|
/// Calculates trip duration in days for an itinerary option.
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ enum TravelEstimator {
|
|||||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||||
let drivingHours = distanceMiles / averageSpeedMph
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
// This allows multi-day cross-country segments like Chicago → Anaheim.
|
||||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||||
if drivingHours > maxAllowedHours {
|
if drivingHours > maxAllowedHours {
|
||||||
return nil
|
return nil
|
||||||
@@ -103,8 +103,8 @@ enum TravelEstimator {
|
|||||||
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||||
let drivingHours = distanceMiles / averageSpeedMph
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
// This allows multi-day cross-country segments like Chicago → Anaheim.
|
||||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||||
if drivingHours > maxAllowedHours {
|
if drivingHours > maxAllowedHours {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
51
SportsTimeTests/Domain/TravelInfoTests.swift
Normal file
51
SportsTimeTests/Domain/TravelInfoTests.swift
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -353,6 +353,36 @@ struct GameDAGRouterTests {
|
|||||||
#expect(!combinedRoutes.isEmpty, "NYC to Chicago over 2 days should be feasible")
|
#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
|
// MARK: - Property Tests
|
||||||
|
|
||||||
@Test("Property: route count never exceeds maxOptions (75)")
|
@Test("Property: route count never exceeds maxOptions (75)")
|
||||||
|
|||||||
@@ -220,6 +220,55 @@ struct ScenarioAPlannerTests {
|
|||||||
#expect(failure.reason == .noGamesInRange)
|
#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
|
// MARK: - Specification Tests: Successful Planning
|
||||||
|
|
||||||
@Test("plan: single game in range returns success with one option")
|
@Test("plan: single game in range returns success with one option")
|
||||||
|
|||||||
@@ -297,6 +297,49 @@ struct ScenarioBPlannerTests {
|
|||||||
// May also fail if no valid date ranges, which is acceptable
|
// 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
|
// MARK: - Specification Tests: Arrival Time Validation
|
||||||
|
|
||||||
@Test("plan: uses arrivalBeforeGameStart validator")
|
@Test("plan: uses arrivalBeforeGameStart validator")
|
||||||
|
|||||||
@@ -197,6 +197,47 @@ struct ScenarioCPlannerTests {
|
|||||||
#expect(failure.reason == .noGamesInRange)
|
#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
|
// MARK: - Specification Tests: Directional Filtering
|
||||||
|
|
||||||
@Test("plan: directional filtering includes stadiums toward destination")
|
@Test("plan: directional filtering includes stadiums toward destination")
|
||||||
|
|||||||
@@ -272,6 +272,63 @@ struct ScenarioDPlannerTests {
|
|||||||
#expect(!options.isEmpty)
|
#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
|
// MARK: - Invariant Tests
|
||||||
|
|
||||||
@Test("Invariant: all returned games have team as home or away")
|
@Test("Invariant: all returned games have team as home or away")
|
||||||
|
|||||||
@@ -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")
|
@Test("plan: routes sorted by duration ascending")
|
||||||
func plan_routesSortedByDurationAscending() {
|
func plan_routesSortedByDurationAscending() {
|
||||||
let baseDate = Date()
|
let baseDate = Date()
|
||||||
|
|||||||
Reference in New Issue
Block a user