perf: optimize featured cross-country trip generation and add tests

This commit is contained in:
Trey t
2026-02-10 20:11:38 -06:00
parent c6fa6386fd
commit e9c15d70b1
3 changed files with 401 additions and 85 deletions

View File

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

View File

@@ -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: EastWest and WestEast 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

View File

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