perf: optimize featured cross-country trip generation and add tests
This commit is contained in:
@@ -154,7 +154,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
throw DataProviderError.contextNotConfigured
|
throw DataProviderError.contextNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let sportStrings = sports.map { $0.rawValue }
|
let sportStrings = Set(sports.map(\.rawValue))
|
||||||
|
|
||||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||||
predicate: #Predicate<CanonicalGame> { game in
|
predicate: #Predicate<CanonicalGame> { game in
|
||||||
@@ -179,7 +179,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
throw DataProviderError.contextNotConfigured
|
throw DataProviderError.contextNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
let sportStrings = sports.map { $0.rawValue }
|
let sportStrings = Set(sports.map(\.rawValue))
|
||||||
|
|
||||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||||
predicate: #Predicate<CanonicalGame> { game in
|
predicate: #Predicate<CanonicalGame> { game in
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ final class SuggestedTripsGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Performs heavy trip generation computation off the main actor.
|
/// Performs heavy trip generation computation off the main actor.
|
||||||
/// Runs all 3 regions + 2 cross-country routes concurrently.
|
/// Runs all 3 regions + up to 2 directional cross-country routes concurrently.
|
||||||
private nonisolated static func generateTripsInBackground(
|
private nonisolated static func generateTripsInBackground(
|
||||||
games: [Game],
|
games: [Game],
|
||||||
stadiums: [Stadium],
|
stadiums: [Stadium],
|
||||||
@@ -222,7 +222,7 @@ final class SuggestedTripsGenerator {
|
|||||||
let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
|
let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
|
||||||
let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
|
let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
|
||||||
|
|
||||||
// Run all 3 regions + 2 cross-country routes concurrently
|
// Run all 3 regions + 2 directional cross-country routes concurrently
|
||||||
async let eastTrips = generateRegionTrips(
|
async let eastTrips = generateRegionTrips(
|
||||||
region: .east, games: games, stadiums: stadiums,
|
region: .east, games: games, stadiums: stadiums,
|
||||||
stadiumsById: stadiumsById, teamsById: teamsById,
|
stadiumsById: stadiumsById, teamsById: teamsById,
|
||||||
@@ -238,22 +238,35 @@ final class SuggestedTripsGenerator {
|
|||||||
stadiumsById: stadiumsById, teamsById: teamsById,
|
stadiumsById: stadiumsById, teamsById: teamsById,
|
||||||
startDate: startDate, endDate: endDate
|
startDate: startDate, endDate: endDate
|
||||||
)
|
)
|
||||||
async let crossCountry1 = generateCrossCountryTrip(
|
async let eastToWestCrossCountry = generateCrossCountryTrip(
|
||||||
games: games, stadiums: stadiumsById, teams: teamsById,
|
games: games,
|
||||||
startDate: startDate, endDate: endDate, excludeGames: []
|
stadiums: stadiumsById,
|
||||||
|
teams: teamsById,
|
||||||
|
direction: .eastToWest
|
||||||
)
|
)
|
||||||
async let crossCountry2 = generateCrossCountryTrip(
|
async let westToEastCrossCountry = generateCrossCountryTrip(
|
||||||
games: games, stadiums: stadiumsById, teams: teamsById,
|
games: games,
|
||||||
startDate: startDate, endDate: endDate,
|
stadiums: stadiumsById,
|
||||||
excludeGames: [] // Can't depend on crossCountry1 without breaking parallelism
|
teams: teamsById,
|
||||||
|
direction: .westToEast
|
||||||
)
|
)
|
||||||
|
|
||||||
var results: [SuggestedTrip] = []
|
var results: [SuggestedTrip] = []
|
||||||
results.append(contentsOf: await eastTrips)
|
results.append(contentsOf: await eastTrips)
|
||||||
results.append(contentsOf: await centralTrips)
|
results.append(contentsOf: await centralTrips)
|
||||||
results.append(contentsOf: await westTrips)
|
results.append(contentsOf: await westTrips)
|
||||||
if let cc1 = await crossCountry1 { results.append(cc1) }
|
|
||||||
if let cc2 = await crossCountry2 { results.append(cc2) }
|
var crossCountrySignatures = Set<String>()
|
||||||
|
if let eastToWest = await eastToWestCrossCountry {
|
||||||
|
results.append(eastToWest)
|
||||||
|
crossCountrySignatures.insert(crossCountrySignature(for: eastToWest))
|
||||||
|
}
|
||||||
|
if let westToEast = await westToEastCrossCountry {
|
||||||
|
let signature = crossCountrySignature(for: westToEast)
|
||||||
|
if !crossCountrySignatures.contains(signature) {
|
||||||
|
results.append(westToEast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
@@ -360,84 +373,65 @@ final class SuggestedTripsGenerator {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CrossCountryDirection {
|
||||||
|
case eastToWest
|
||||||
|
case westToEast
|
||||||
|
}
|
||||||
|
|
||||||
private nonisolated static func generateCrossCountryTrip(
|
private nonisolated static func generateCrossCountryTrip(
|
||||||
games: [Game],
|
games: [Game],
|
||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
teams: [String: Team],
|
teams: [String: Team],
|
||||||
startDate: Date,
|
direction: CrossCountryDirection
|
||||||
endDate: Date,
|
|
||||||
excludeGames: [Game]
|
|
||||||
) -> SuggestedTrip? {
|
) -> SuggestedTrip? {
|
||||||
|
guard !games.isEmpty else { return nil }
|
||||||
let excludeIds = Set(excludeGames.map { $0.id })
|
|
||||||
let availableGames = games.filter { !excludeIds.contains($0.id) }
|
|
||||||
|
|
||||||
guard !availableGames.isEmpty else { return nil }
|
|
||||||
|
|
||||||
// Group games by region
|
// Group games by region
|
||||||
var gamesByRegion: [Region: [Game]] = [:]
|
var gamesByRegion: [Region: [Game]] = [:]
|
||||||
for game in availableGames {
|
for game in games {
|
||||||
guard let stadium = stadiums[game.stadiumId] else { continue }
|
guard let stadium = stadiums[game.stadiumId] else { continue }
|
||||||
gamesByRegion[stadium.region, default: []].append(game)
|
gamesByRegion[stadium.region, default: []].append(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("📍 CrossCountry: Games by region - East: \(gamesByRegion[.east]?.count ?? 0), Central: \(gamesByRegion[.central]?.count ?? 0), West: \(gamesByRegion[.west]?.count ?? 0)")
|
|
||||||
|
|
||||||
// REQUIREMENT: Must have games in BOTH East AND West for a true coast-to-coast
|
// REQUIREMENT: Must have games in BOTH East AND West for a true coast-to-coast
|
||||||
guard let eastGames = gamesByRegion[.east], !eastGames.isEmpty,
|
guard let eastGames = gamesByRegion[.east], !eastGames.isEmpty,
|
||||||
let westGames = gamesByRegion[.west], !westGames.isEmpty else {
|
let westGames = gamesByRegion[.west], !westGames.isEmpty else {
|
||||||
print("❌ CrossCountry: Need games in BOTH East and West regions")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let centralGames = gamesByRegion[.central] ?? []
|
let centralGames = gamesByRegion[.central] ?? []
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
|
|
||||||
// Try both directions: East→West and West→East
|
let selectedGames: [Game]
|
||||||
let eastToWest = buildCoastToCoastRoute(
|
switch direction {
|
||||||
startGames: eastGames,
|
case .eastToWest:
|
||||||
middleGames: centralGames,
|
selectedGames = buildCoastToCoastRoute(
|
||||||
endGames: westGames,
|
startGames: eastGames,
|
||||||
stadiums: stadiums,
|
middleGames: centralGames,
|
||||||
calendar: calendar
|
endGames: westGames,
|
||||||
)
|
stadiums: stadiums,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
case .westToEast:
|
||||||
|
selectedGames = buildCoastToCoastRoute(
|
||||||
|
startGames: westGames,
|
||||||
|
middleGames: centralGames,
|
||||||
|
endGames: eastGames,
|
||||||
|
stadiums: stadiums,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let westToEast = buildCoastToCoastRoute(
|
guard selectedGames.count >= 3 else {
|
||||||
startGames: westGames,
|
|
||||||
middleGames: centralGames,
|
|
||||||
endGames: eastGames,
|
|
||||||
stadiums: stadiums,
|
|
||||||
calendar: calendar
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pick the better route (more games)
|
|
||||||
var selectedGames: [Game]
|
|
||||||
if eastToWest.count >= westToEast.count && eastToWest.count >= 3 {
|
|
||||||
selectedGames = eastToWest
|
|
||||||
print("🧭 CrossCountry: Using East→West route")
|
|
||||||
} else if westToEast.count >= 3 {
|
|
||||||
selectedGames = westToEast
|
|
||||||
print("🧭 CrossCountry: Using West→East route")
|
|
||||||
} else {
|
|
||||||
print("❌ CrossCountry: No valid coast-to-coast route found (E→W: \(eastToWest.count), W→E: \(westToEast.count))")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the route spans both coasts
|
// Validate the route spans both coasts
|
||||||
let routeRegions = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.region })
|
let routeRegions = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.region })
|
||||||
guard routeRegions.contains(.east) && routeRegions.contains(.west) else {
|
guard routeRegions.contains(.east) && routeRegions.contains(.west) else {
|
||||||
print("❌ CrossCountry: Route doesn't span both coasts: \(routeRegions)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: show selected cities and their regions
|
|
||||||
let cityDetails = selectedGames.compactMap { game -> String? in
|
|
||||||
guard let stadium = stadiums[game.stadiumId] else { return nil }
|
|
||||||
let dateStr = game.dateTime.formatted(date: .abbreviated, time: .omitted)
|
|
||||||
return "\(stadium.city)(\(stadium.region.rawValue.prefix(1)),\(dateStr))"
|
|
||||||
}
|
|
||||||
print("✅ CrossCountry: Selected \(selectedGames.count) games: \(cityDetails.joined(separator: " → "))")
|
|
||||||
|
|
||||||
// Build trip directly from selected games (bypass planning engine)
|
// Build trip directly from selected games (bypass planning engine)
|
||||||
guard let firstGame = selectedGames.first,
|
guard let firstGame = selectedGames.first,
|
||||||
let lastGame = selectedGames.last else { return nil }
|
let lastGame = selectedGames.last else { return nil }
|
||||||
@@ -474,12 +468,9 @@ final class SuggestedTripsGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard tripStops.count >= 3 else {
|
guard tripStops.count >= 3 else {
|
||||||
print("❌ CrossCountry: Only \(tripStops.count) stops built (need at least 3)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
print("✅ CrossCountry: Built trip with \(tripStops.count) stops")
|
|
||||||
|
|
||||||
// Build the trip
|
// Build the trip
|
||||||
let preferences = TripPreferences(
|
let preferences = TripPreferences(
|
||||||
planningMode: .dateRange,
|
planningMode: .dateRange,
|
||||||
@@ -554,14 +545,17 @@ final class SuggestedTripsGenerator {
|
|||||||
|
|
||||||
var segments: [TravelSegment] = []
|
var segments: [TravelSegment] = []
|
||||||
let constraints = DrivingConstraints.default
|
let constraints = DrivingConstraints.default
|
||||||
|
let coordinatesByCity = stadiums.values.reduce(into: [String: CLLocationCoordinate2D]()) { partialResult, stadium in
|
||||||
|
partialResult[stadium.city] = stadium.coordinate
|
||||||
|
}
|
||||||
|
|
||||||
for i in 0..<(stops.count - 1) {
|
for i in 0..<(stops.count - 1) {
|
||||||
let fromStop = stops[i]
|
let fromStop = stops[i]
|
||||||
let toStop = stops[i + 1]
|
let toStop = stops[i + 1]
|
||||||
|
|
||||||
// Get coordinates from stops or from stadium lookup
|
// Get coordinates from stops or from stadium lookup
|
||||||
let fromCoord = fromStop.coordinate ?? stadiums.values.first { $0.city == fromStop.city }?.coordinate
|
let fromCoord = fromStop.coordinate ?? coordinatesByCity[fromStop.city]
|
||||||
let toCoord = toStop.coordinate ?? stadiums.values.first { $0.city == toStop.city }?.coordinate
|
let toCoord = toStop.coordinate ?? coordinatesByCity[toStop.city]
|
||||||
|
|
||||||
let fromLocation = LocationInput(name: fromStop.city, coordinate: fromCoord)
|
let fromLocation = LocationInput(name: fromStop.city, coordinate: fromCoord)
|
||||||
let toLocation = LocationInput(name: toStop.city, coordinate: toCoord)
|
let toLocation = LocationInput(name: toStop.city, coordinate: toCoord)
|
||||||
@@ -576,8 +570,6 @@ final class SuggestedTripsGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
private nonisolated static func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
||||||
print("🔍 convertToTrip: option.stops.count = \(option.stops.count)")
|
|
||||||
|
|
||||||
let tripStops = option.stops.enumerated().map { index, stop in
|
let tripStops = option.stops.enumerated().map { index, stop in
|
||||||
TripStop(
|
TripStop(
|
||||||
stopNumber: index + 1,
|
stopNumber: index + 1,
|
||||||
@@ -591,8 +583,6 @@ final class SuggestedTripsGenerator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("🔍 convertToTrip: tripStops.count = \(tripStops.count)")
|
|
||||||
|
|
||||||
return Trip(
|
return Trip(
|
||||||
name: generateTripName(from: tripStops),
|
name: generateTripName(from: tripStops),
|
||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
@@ -695,18 +685,24 @@ final class SuggestedTripsGenerator {
|
|||||||
stadiums: [String: Stadium],
|
stadiums: [String: Stadium],
|
||||||
calendar: Calendar
|
calendar: Calendar
|
||||||
) -> [Game] {
|
) -> [Game] {
|
||||||
|
let maxAnchorsPerSide = 32
|
||||||
|
|
||||||
// Sort all games by date
|
// Sort all games by date
|
||||||
let sortedStart = startGames.sorted { $0.dateTime < $1.dateTime }
|
let sortedStart = startGames.sorted { $0.dateTime < $1.dateTime }
|
||||||
let sortedMiddle = middleGames.sorted { $0.dateTime < $1.dateTime }
|
let sortedMiddle = middleGames.sorted { $0.dateTime < $1.dateTime }
|
||||||
let sortedEnd = endGames.sorted { $0.dateTime < $1.dateTime }
|
let sortedEnd = endGames.sorted { $0.dateTime < $1.dateTime }
|
||||||
|
|
||||||
|
// Bound the start/end search space to keep route generation responsive.
|
||||||
|
let candidateStarts = trimAnchorGames(sortedStart, calendar: calendar, keep: maxAnchorsPerSide)
|
||||||
|
let candidateEnds = trimAnchorGames(sortedEnd, calendar: calendar, keep: maxAnchorsPerSide)
|
||||||
|
|
||||||
// Maximum driving per day (roughly 500 miles)
|
// Maximum driving per day (roughly 500 miles)
|
||||||
let maxDailyMiles = 500.0
|
let maxDailyMiles = 500.0
|
||||||
|
|
||||||
// Collect all valid routes to pick the shortest
|
// Track only the best route found instead of collecting and sorting all candidates.
|
||||||
var validRoutes: [(games: [Game], days: Int, distance: Double)] = []
|
var bestRoute: (games: [Game], days: Int, distance: Double)?
|
||||||
|
|
||||||
for startGame in sortedStart {
|
for startGame in candidateStarts {
|
||||||
guard let startStadium = stadiums[startGame.stadiumId] else { continue }
|
guard let startStadium = stadiums[startGame.stadiumId] else { continue }
|
||||||
|
|
||||||
// Find ending games that are AFTER the start game with enough time to drive cross-country
|
// Find ending games that are AFTER the start game with enough time to drive cross-country
|
||||||
@@ -714,7 +710,7 @@ final class SuggestedTripsGenerator {
|
|||||||
let minDaysForCrossCountry = 5
|
let minDaysForCrossCountry = 5
|
||||||
let maxDaysForTrip = 14 // Cap trips at 2 weeks for reasonable length
|
let maxDaysForTrip = 14 // Cap trips at 2 weeks for reasonable length
|
||||||
|
|
||||||
for endGame in sortedEnd {
|
for endGame in candidateEnds {
|
||||||
guard let endStadium = stadiums[endGame.stadiumId] else { continue }
|
guard let endStadium = stadiums[endGame.stadiumId] else { continue }
|
||||||
|
|
||||||
let daysBetween = calendar.dateComponents([.day], from: startGame.dateTime, to: endGame.dateTime).day ?? 0
|
let daysBetween = calendar.dateComponents([.day], from: startGame.dateTime, to: endGame.dateTime).day ?? 0
|
||||||
@@ -722,6 +718,16 @@ final class SuggestedTripsGenerator {
|
|||||||
// Need enough time to drive cross-country but not TOO long
|
// Need enough time to drive cross-country but not TOO long
|
||||||
guard daysBetween >= minDaysForCrossCountry && daysBetween <= maxDaysForTrip else { continue }
|
guard daysBetween >= minDaysForCrossCountry && daysBetween <= maxDaysForTrip else { continue }
|
||||||
|
|
||||||
|
// Early prune impossible anchor pairs before scanning middle games.
|
||||||
|
let maxMilesForWindow = Double(max(1, daysBetween)) * maxDailyMiles
|
||||||
|
let directDistance = haversineDistance(
|
||||||
|
lat1: startStadium.coordinate.latitude,
|
||||||
|
lon1: startStadium.coordinate.longitude,
|
||||||
|
lat2: endStadium.coordinate.latitude,
|
||||||
|
lon2: endStadium.coordinate.longitude
|
||||||
|
)
|
||||||
|
guard directDistance <= maxMilesForWindow else { continue }
|
||||||
|
|
||||||
// Build route: start → middle games → end
|
// Build route: start → middle games → end
|
||||||
var route: [Game] = [startGame]
|
var route: [Game] = [startGame]
|
||||||
var lastGame = startGame
|
var lastGame = startGame
|
||||||
@@ -810,23 +816,22 @@ final class SuggestedTripsGenerator {
|
|||||||
|
|
||||||
// Final validation: ensure no same-day conflicts in entire route
|
// Final validation: ensure no same-day conflicts in entire route
|
||||||
if validateNoSameDayConflicts(route, calendar: calendar) && route.count >= 3 {
|
if validateNoSameDayConflicts(route, calendar: calendar) && route.count >= 3 {
|
||||||
validRoutes.append((games: route, days: daysBetween, distance: totalDistance))
|
let candidate = (games: route, days: daysBetween, distance: totalDistance)
|
||||||
|
if let currentBest = bestRoute {
|
||||||
|
if candidate.days < currentBest.days ||
|
||||||
|
(candidate.days == currentBest.days && candidate.distance < currentBest.distance) {
|
||||||
|
bestRoute = candidate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bestRoute = candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort routes: prefer fewer days, then less distance
|
|
||||||
let sortedRoutes = validRoutes.sorted { a, b in
|
|
||||||
if a.days != b.days {
|
|
||||||
return a.days < b.days
|
|
||||||
}
|
|
||||||
return a.distance < b.distance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the shortest valid route
|
// Return the shortest valid route
|
||||||
if let best = sortedRoutes.first {
|
if let best = bestRoute {
|
||||||
print("✅ C2C route: \(best.days) days, \(Int(best.distance)) miles, \(best.games.count) games")
|
|
||||||
return best.games
|
return best.games
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +845,6 @@ final class SuggestedTripsGenerator {
|
|||||||
let day = calendar.startOfDay(for: game.dateTime)
|
let day = calendar.startOfDay(for: game.dateTime)
|
||||||
if let existingStadiumId = gamesByDay[day] {
|
if let existingStadiumId = gamesByDay[day] {
|
||||||
if game.stadiumId != existingStadiumId {
|
if game.stadiumId != existingStadiumId {
|
||||||
print("⚠️ Same-day conflict: \(day) has games at different stadiums")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -849,6 +853,50 @@ final class SuggestedTripsGenerator {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func crossCountrySignature(for trip: SuggestedTrip) -> String {
|
||||||
|
trip.trip.stops.flatMap(\.games).joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples anchor games across the full time window while enforcing one game per day.
|
||||||
|
private nonisolated static func trimAnchorGames(_ games: [Game], calendar: Calendar, keep: Int) -> [Game] {
|
||||||
|
guard keep > 0 else { return [] }
|
||||||
|
guard games.count > keep else { return games }
|
||||||
|
|
||||||
|
var uniqueDays: [Game] = []
|
||||||
|
var seenDays = Set<Date>()
|
||||||
|
for game in games {
|
||||||
|
let day = calendar.startOfDay(for: game.dateTime)
|
||||||
|
if seenDays.insert(day).inserted {
|
||||||
|
uniqueDays.append(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = uniqueDays.isEmpty ? games : uniqueDays
|
||||||
|
guard source.count > keep else { return source }
|
||||||
|
|
||||||
|
let step = max(1, source.count / keep)
|
||||||
|
var sampled: [Game] = []
|
||||||
|
var seenGameIds = Set<String>()
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < source.count && sampled.count < keep {
|
||||||
|
let game = source[index]
|
||||||
|
if seenGameIds.insert(game.id).inserted {
|
||||||
|
sampled.append(game)
|
||||||
|
}
|
||||||
|
index += step
|
||||||
|
}
|
||||||
|
|
||||||
|
if sampled.count < keep {
|
||||||
|
for game in source where seenGameIds.insert(game.id).inserted {
|
||||||
|
sampled.append(game)
|
||||||
|
if sampled.count == keep { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampled
|
||||||
|
}
|
||||||
|
|
||||||
/// Haversine distance in miles between two coordinates
|
/// Haversine distance in miles between two coordinates
|
||||||
private nonisolated static func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
private nonisolated static func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||||
let R = 3959.0 // Earth radius in miles
|
let R = 3959.0 // Earth radius in miles
|
||||||
@@ -861,3 +909,39 @@ final class SuggestedTripsGenerator {
|
|||||||
return R * c
|
return R * c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension SuggestedTripsGenerator {
|
||||||
|
/// Test hook for directional cross-country generation without AppDataProvider wiring.
|
||||||
|
nonisolated static func _testGenerateCrossCountryTrip(
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [String: Stadium],
|
||||||
|
teams: [String: Team],
|
||||||
|
eastToWest: Bool
|
||||||
|
) -> SuggestedTrip? {
|
||||||
|
generateCrossCountryTrip(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiums,
|
||||||
|
teams: teams,
|
||||||
|
direction: eastToWest ? .eastToWest : .westToEast
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test hook for direct coast-to-coast route construction.
|
||||||
|
nonisolated static func _testBuildCoastToCoastRoute(
|
||||||
|
startGames: [Game],
|
||||||
|
middleGames: [Game],
|
||||||
|
endGames: [Game],
|
||||||
|
stadiums: [String: Stadium],
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) -> [Game] {
|
||||||
|
buildCoastToCoastRoute(
|
||||||
|
startGames: startGames,
|
||||||
|
middleGames: middleGames,
|
||||||
|
endGames: endGames,
|
||||||
|
stadiums: stadiums,
|
||||||
|
calendar: calendar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -253,3 +253,235 @@ struct HaversineDistanceTests {
|
|||||||
return R * c
|
return R * c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cross-Country Feature Trip Tests
|
||||||
|
|
||||||
|
@Suite("SuggestedTripsGenerator Cross-Country")
|
||||||
|
struct CrossCountryFeatureTripTests {
|
||||||
|
|
||||||
|
private struct CitySeed {
|
||||||
|
let name: String
|
||||||
|
let state: String
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20 known US cities spanning east/central/west regions.
|
||||||
|
private let citySeeds: [CitySeed] = [
|
||||||
|
CitySeed(name: "Boston", state: "MA", latitude: 42.3601, longitude: -71.0589),
|
||||||
|
CitySeed(name: "New York", state: "NY", latitude: 40.7128, longitude: -74.0060),
|
||||||
|
CitySeed(name: "Philadelphia", state: "PA", latitude: 39.9526, longitude: -75.1652),
|
||||||
|
CitySeed(name: "Baltimore", state: "MD", latitude: 39.2904, longitude: -76.6122),
|
||||||
|
CitySeed(name: "Washington", state: "DC", latitude: 38.9072, longitude: -77.0369),
|
||||||
|
CitySeed(name: "Charlotte", state: "NC", latitude: 35.2271, longitude: -80.8431),
|
||||||
|
CitySeed(name: "Atlanta", state: "GA", latitude: 33.7490, longitude: -84.3880),
|
||||||
|
CitySeed(name: "Nashville", state: "TN", latitude: 36.1627, longitude: -86.7816),
|
||||||
|
CitySeed(name: "St Louis", state: "MO", latitude: 38.6270, longitude: -90.1994),
|
||||||
|
CitySeed(name: "Chicago", state: "IL", latitude: 41.8781, longitude: -87.6298),
|
||||||
|
CitySeed(name: "Minneapolis", state: "MN", latitude: 44.9778, longitude: -93.2650),
|
||||||
|
CitySeed(name: "Kansas City", state: "MO", latitude: 39.0997, longitude: -94.5786),
|
||||||
|
CitySeed(name: "Dallas", state: "TX", latitude: 32.7767, longitude: -96.7970),
|
||||||
|
CitySeed(name: "Denver", state: "CO", latitude: 39.7392, longitude: -104.9903),
|
||||||
|
CitySeed(name: "Albuquerque", state: "NM", latitude: 35.0844, longitude: -106.6504),
|
||||||
|
CitySeed(name: "Phoenix", state: "AZ", latitude: 33.4484, longitude: -112.0740),
|
||||||
|
CitySeed(name: "Las Vegas", state: "NV", latitude: 36.1699, longitude: -115.1398),
|
||||||
|
CitySeed(name: "Los Angeles", state: "CA", latitude: 34.0522, longitude: -118.2437),
|
||||||
|
CitySeed(name: "San Diego", state: "CA", latitude: 32.7157, longitude: -117.1611),
|
||||||
|
CitySeed(name: "Seattle", state: "WA", latitude: 47.6062, longitude: -122.3321),
|
||||||
|
]
|
||||||
|
|
||||||
|
private func canonicalToken(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: ".", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStadium(from city: CitySeed) -> Stadium {
|
||||||
|
let token = canonicalToken(city.name)
|
||||||
|
return Stadium(
|
||||||
|
id: "stadium_test_\(token)",
|
||||||
|
name: "\(city.name) Test Stadium",
|
||||||
|
city: city.name,
|
||||||
|
state: city.state,
|
||||||
|
latitude: city.latitude,
|
||||||
|
longitude: city.longitude,
|
||||||
|
capacity: 40000,
|
||||||
|
sport: .mlb
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTeams(for stadium: Stadium) -> [Team] {
|
||||||
|
let token = canonicalToken(stadium.city)
|
||||||
|
let home = Team(
|
||||||
|
id: "team_test_home_\(token)",
|
||||||
|
name: "\(stadium.city) Home",
|
||||||
|
abbreviation: String(token.prefix(3)).uppercased(),
|
||||||
|
sport: .mlb,
|
||||||
|
city: stadium.city,
|
||||||
|
stadiumId: stadium.id
|
||||||
|
)
|
||||||
|
let away = Team(
|
||||||
|
id: "team_test_away_\(token)",
|
||||||
|
name: "\(stadium.city) Away",
|
||||||
|
abbreviation: "A\(String(token.prefix(2)).uppercased())",
|
||||||
|
sport: .mlb,
|
||||||
|
city: stadium.city,
|
||||||
|
stadiumId: stadium.id
|
||||||
|
)
|
||||||
|
return [home, away]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeGames(
|
||||||
|
from stadiums: [Stadium],
|
||||||
|
startDate: Date,
|
||||||
|
spacingDays: Int = 1,
|
||||||
|
idPrefix: String
|
||||||
|
) -> [Game] {
|
||||||
|
var games: [Game] = []
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
for (index, stadium) in stadiums.enumerated() {
|
||||||
|
let gameDate = calendar.date(byAdding: .day, value: index * spacingDays, to: startDate) ?? startDate
|
||||||
|
let token = canonicalToken(stadium.city)
|
||||||
|
games.append(
|
||||||
|
Game(
|
||||||
|
id: "game_test_\(idPrefix)_\(token)_\(index)",
|
||||||
|
homeTeamId: "team_test_home_\(token)",
|
||||||
|
awayTeamId: "team_test_away_\(token)",
|
||||||
|
stadiumId: stadium.id,
|
||||||
|
dateTime: gameDate,
|
||||||
|
sport: .mlb,
|
||||||
|
season: "2026"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return games
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDataset(spacingDays: Int = 1) -> (games: [Game], stadiumsById: [String: Stadium], teamsById: [String: Team]) {
|
||||||
|
let stadiums = citySeeds.map(makeStadium)
|
||||||
|
let teams = stadiums.flatMap(makeTeams)
|
||||||
|
let sortedEastToWest = stadiums.sorted { $0.longitude > $1.longitude }
|
||||||
|
let sortedWestToEast = Array(sortedEastToWest.reversed())
|
||||||
|
let baseDate = Date(timeIntervalSince1970: 1_736_000_000) // Fixed baseline for deterministic test behavior
|
||||||
|
|
||||||
|
let eastToWestGames = makeGames(
|
||||||
|
from: sortedEastToWest,
|
||||||
|
startDate: baseDate,
|
||||||
|
spacingDays: spacingDays,
|
||||||
|
idPrefix: "e2w"
|
||||||
|
)
|
||||||
|
|
||||||
|
let secondLegStart = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: (sortedEastToWest.count * spacingDays) + 2,
|
||||||
|
to: baseDate
|
||||||
|
) ?? baseDate
|
||||||
|
|
||||||
|
let westToEastGames = makeGames(
|
||||||
|
from: sortedWestToEast,
|
||||||
|
startDate: secondLegStart,
|
||||||
|
spacingDays: spacingDays,
|
||||||
|
idPrefix: "w2e"
|
||||||
|
)
|
||||||
|
|
||||||
|
let games = eastToWestGames + westToEastGames
|
||||||
|
|
||||||
|
let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { partialResult, stadium in
|
||||||
|
partialResult[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
let teamsById = teams.reduce(into: [String: Team]()) { partialResult, team in
|
||||||
|
partialResult[team.id] = team
|
||||||
|
}
|
||||||
|
|
||||||
|
return (games: games, stadiumsById: stadiumsById, teamsById: teamsById)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func routeRegions(for trip: SuggestedTrip, stadiumsById: [String: Stadium]) -> Set<Region> {
|
||||||
|
let gameIdsInTrip = Set(trip.trip.stops.flatMap(\.games))
|
||||||
|
let tripGames = trip.richGames.values
|
||||||
|
.map(\.game)
|
||||||
|
.filter { gameIdsInTrip.contains($0.id) }
|
||||||
|
|
||||||
|
return Set(tripGames.compactMap { game in
|
||||||
|
stadiumsById[game.stadiumId]?.region
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cross-country (20 cities): east-to-west generates a valid coast-to-coast trip")
|
||||||
|
func crossCountry_eastToWest_fromTwentyCities() {
|
||||||
|
let (games, stadiumsById, teamsById) = makeDataset()
|
||||||
|
|
||||||
|
let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiumsById,
|
||||||
|
teams: teamsById,
|
||||||
|
eastToWest: true
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(trip != nil)
|
||||||
|
guard let trip else { return }
|
||||||
|
|
||||||
|
#expect(trip.region == .crossCountry)
|
||||||
|
#expect(trip.trip.stops.count >= 3)
|
||||||
|
#expect(trip.trip.totalGames >= 3)
|
||||||
|
|
||||||
|
let regions = routeRegions(for: trip, stadiumsById: stadiumsById)
|
||||||
|
#expect(regions.contains(.east))
|
||||||
|
#expect(regions.contains(.west))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cross-country (20 cities): west-to-east generates a valid coast-to-coast trip")
|
||||||
|
func crossCountry_westToEast_fromTwentyCities() {
|
||||||
|
let (games, stadiumsById, teamsById) = makeDataset()
|
||||||
|
|
||||||
|
let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiumsById,
|
||||||
|
teams: teamsById,
|
||||||
|
eastToWest: false
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(trip != nil)
|
||||||
|
guard let trip else { return }
|
||||||
|
|
||||||
|
#expect(trip.region == .crossCountry)
|
||||||
|
#expect(trip.trip.stops.count >= 3)
|
||||||
|
#expect(trip.trip.totalGames >= 3)
|
||||||
|
|
||||||
|
let regions = routeRegions(for: trip, stadiumsById: stadiumsById)
|
||||||
|
#expect(regions.contains(.east))
|
||||||
|
#expect(regions.contains(.west))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cross-country performance: 20-city dataset stays under target average runtime")
|
||||||
|
func crossCountry_generationPerformance_twentyCityDataset() {
|
||||||
|
let (games, stadiumsById, teamsById) = makeDataset()
|
||||||
|
let iterations = 20
|
||||||
|
var elapsedMillis: [Double] = []
|
||||||
|
|
||||||
|
for _ in 0..<iterations {
|
||||||
|
let start = DispatchTime.now().uptimeNanoseconds
|
||||||
|
let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiumsById,
|
||||||
|
teams: teamsById,
|
||||||
|
eastToWest: true
|
||||||
|
)
|
||||||
|
let end = DispatchTime.now().uptimeNanoseconds
|
||||||
|
#expect(trip != nil)
|
||||||
|
|
||||||
|
let millis = Double(end - start) / 1_000_000
|
||||||
|
elapsedMillis.append(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
let averageMillis = elapsedMillis.reduce(0, +) / Double(elapsedMillis.count)
|
||||||
|
let worstMillis = elapsedMillis.max() ?? 0
|
||||||
|
|
||||||
|
print("Cross-country benchmark (20-city): avg=\(averageMillis)ms worst=\(worstMillis)ms over \(iterations) runs")
|
||||||
|
|
||||||
|
#expect(averageMillis < 250.0, "Average generation time was \(averageMillis)ms")
|
||||||
|
#expect(worstMillis < 500.0, "Worst generation time was \(worstMillis)ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user