Files
Sportstime/SportsTimeTests/Planning/GameDAGRouterTests.swift
Trey t 9736773475 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>
2026-02-13 08:55:23 -06:00

604 lines
24 KiB
Swift

//
// GameDAGRouterTests.swift
// SportsTimeTests
//
// TDD specification + property tests for GameDAGRouter.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("GameDAGRouter")
struct GameDAGRouterTests {
// MARK: - Test Data
private let constraints = DrivingConstraints.default // 1 driver, 8 hrs/day
// Stadium locations (real coordinates)
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0141, longitude: -118.2879)
private let seattleCoord = CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3316)
private let calendar = Calendar.current
// MARK: - Specification Tests: Edge Cases
@Test("findRoutes: empty games returns empty")
func findRoutes_emptyGames_returnsEmpty() {
let routes = GameDAGRouter.findRoutes(
games: [],
stadiums: [:],
constraints: constraints
)
#expect(routes.isEmpty)
}
@Test("findRoutes: single game with no anchors returns single-game route")
func findRoutes_singleGame_noAnchors_returnsSingleRoute() {
let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())
let routes = GameDAGRouter.findRoutes(
games: [game],
stadiums: [stadium.id: stadium],
constraints: constraints
)
#expect(routes.count == 1)
#expect(routes.first?.count == 1)
#expect(routes.first?.first?.id == game.id)
}
@Test("findRoutes: single game matching anchor returns single-game route")
func findRoutes_singleGame_matchingAnchor_returnsSingleRoute() {
let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())
let routes = GameDAGRouter.findRoutes(
games: [game],
stadiums: [stadium.id: stadium],
constraints: constraints,
anchorGameIds: [game.id]
)
#expect(routes.count == 1)
#expect(routes.first?.first?.id == game.id)
}
@Test("findRoutes: single game not matching anchor returns empty")
func findRoutes_singleGame_notMatchingAnchor_returnsEmpty() {
let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())
let routes = GameDAGRouter.findRoutes(
games: [game],
stadiums: [stadium.id: stadium],
constraints: constraints,
anchorGameIds: ["different-game-id"]
)
#expect(routes.isEmpty)
}
// MARK: - Specification Tests: Two Games
@Test("findRoutes: two feasible games returns combined route")
func findRoutes_twoFeasibleGames_returnsCombinedRoute() {
let today = calendar.startOfDay(for: Date())
let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
let game2Date = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: today)!
// NYC to Philly is ~95 miles, ~1.5 hours - very feasible same day
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Philadelphia", date: game2Date, coord: phillyCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints
)
// Should have at least one route with both games
let combinedRoute = routes.first(where: { $0.count == 2 })
#expect(combinedRoute != nil)
// First game should be earlier one
#expect(combinedRoute?.first?.startTime ?? Date.distantFuture < combinedRoute?.last?.startTime ?? Date.distantPast)
}
@Test("findRoutes: two infeasible same-day games returns separate routes when no anchors")
func findRoutes_twoInfeasibleGames_noAnchors_returnsSeparateRoutes() {
let today = calendar.startOfDay(for: Date())
let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
let game2Date = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)! // Only 2 hours later
// NYC to Chicago is ~790 miles - impossible in 2 hours
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints
)
// Should return separate single-game routes
#expect(routes.count == 2)
#expect(routes.allSatisfy { $0.count == 1 })
}
@Test("findRoutes: two infeasible games with both as anchors returns empty")
func findRoutes_twoInfeasibleGames_bothAnchors_returnsEmpty() {
let today = calendar.startOfDay(for: Date())
let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
let game2Date = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)!
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints,
anchorGameIds: [game1.id, game2.id] // Both must be in route
)
#expect(routes.isEmpty) // Can't satisfy both anchors
}
// MARK: - Specification Tests: Anchor Games
@Test("findRoutes: routes contain all anchor games")
func findRoutes_routesContainAllAnchors() {
let today = calendar.startOfDay(for: Date())
let dates = (0..<5).map { dayOffset in
calendar.date(byAdding: .day, value: dayOffset, to: today)!
}
// Create 5 games over 5 days, all nearby (East Coast)
let gamesAndStadiums = [
makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord),
makeGameAndStadium(city: "New York2", date: dates[3], coord: nycCoord),
makeGameAndStadium(city: "Philadelphia2", date: dates[4], coord: phillyCoord)
]
let games = gamesAndStadiums.map { $0.0 }
var stadiums: [String: Stadium] = [:]
for (_, stadium) in gamesAndStadiums {
stadiums[stadium.id] = stadium
}
// Use first and third games as anchors
let anchorIds: Set<String> = [games[0].id, games[2].id]
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints,
anchorGameIds: anchorIds
)
// All routes must contain both anchor games
for route in routes {
let routeIds = Set(route.map { $0.id })
#expect(anchorIds.isSubset(of: routeIds), "Route must contain all anchors")
}
}
// MARK: - Specification Tests: Repeat Cities
@Test("findRoutes: allowRepeatCities=false excludes routes with duplicate cities")
func findRoutes_disallowRepeatCities_excludesDuplicates() {
let today = calendar.startOfDay(for: Date())
let dates = (0..<3).map { dayOffset in
calendar.date(byAdding: .day, value: dayOffset, to: today)!
}
// NYC -> Boston -> NYC would be a repeat
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city as game1
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2, game3],
stadiums: stadiums,
constraints: constraints,
allowRepeatCities: false
)
// No route should have both game1 and game3 (same city)
for route in routes {
let cities = route.compactMap { game in stadiums[game.stadiumId]?.city }
let uniqueCities = Set(cities)
#expect(cities.count == uniqueCities.count, "No duplicate cities when allowRepeatCities=false")
}
}
@Test("findRoutes: allowRepeatCities=true allows routes with duplicate cities")
func findRoutes_allowRepeatCities_allowsDuplicates() {
let today = calendar.startOfDay(for: Date())
let dates = (0..<3).map { dayOffset in
calendar.date(byAdding: .day, value: dayOffset, to: today)!
}
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city
// Use same stadium ID for same city
let nyStadium = stadium1
let bosStadium = stadium2
var game3Modified = game3
game3Modified = Game(
id: game3.id,
homeTeamId: game3.homeTeamId,
awayTeamId: game3.awayTeamId,
stadiumId: nyStadium.id, // Same stadium as game1
dateTime: game3.dateTime,
sport: game3.sport,
season: game3.season,
isPlayoff: game3.isPlayoff
)
let stadiums = [nyStadium.id: nyStadium, bosStadium.id: bosStadium]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2, game3Modified],
stadiums: stadiums,
constraints: constraints,
allowRepeatCities: true
)
// Should have routes with all 3 games
let fullRoutes = routes.filter { $0.count == 3 }
#expect(!fullRoutes.isEmpty, "Should allow routes with same city when allowRepeatCities=true")
}
// MARK: - Specification Tests: Chronological Order
@Test("findRoutes: all routes are chronologically ordered")
func findRoutes_allRoutesChronological() {
let today = calendar.startOfDay(for: Date())
let dates = (0..<5).map { dayOffset in
calendar.date(byAdding: .day, value: dayOffset, to: today)!
}
let gamesAndStadiums = [
makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord),
makeGameAndStadium(city: "New York2", date: dates[3], coord: nycCoord),
makeGameAndStadium(city: "Philadelphia2", date: dates[4], coord: phillyCoord)
]
let games = gamesAndStadiums.map { $0.0 }
var stadiums: [String: Stadium] = [:]
for (_, stadium) in gamesAndStadiums {
stadiums[stadium.id] = stadium
}
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
for route in routes {
for i in 1..<route.count {
#expect(route[i].startTime > route[i-1].startTime,
"Games must be in chronological order")
}
}
}
// MARK: - Specification Tests: Driving Constraints
@Test("findRoutes: respects maxDailyDrivingHours for same-day games")
func findRoutes_respectsSameDayDrivingLimit() {
let today = calendar.startOfDay(for: Date())
let game1Time = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
let game2Time = calendar.date(bySettingHour: 20, minute: 0, second: 0, of: today)!
// NYC to Chicago: ~790 miles, ~13 hours driving
// With 7 hours between games and 2hr buffer, only 5 hours available
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Time, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Time, coord: chicagoCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints // 8 hr max per day
)
// Should NOT have a route with both games on same day
let sameDayRoutes = routes.filter { $0.count == 2 }
#expect(sameDayRoutes.isEmpty, "NYC to Chicago same-day should be infeasible")
}
@Test("findRoutes: multi-day trips allow longer total driving")
func findRoutes_multiDayTrips_allowLongerDriving() {
let today = calendar.startOfDay(for: Date())
let game1Date = today
let game2Date = calendar.date(byAdding: .day, value: 2, to: today)! // 2 days later
// NYC to Chicago: ~790 miles, ~13 hours driving
// With 2 days between, should be feasible (16 hours available)
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints
)
// Should have a route with both games
let combinedRoutes = routes.filter { $0.count == 2 }
#expect(!combinedRoutes.isEmpty, "NYC to Chicago over 2 days should be feasible")
}
@Test("findRoutes: anchor routes can span gaps larger than 5 days")
func findRoutes_anchorRoutesAllowLongDateGaps() {
let today = calendar.startOfDay(for: Date())
let day0 = today
let day1 = calendar.date(byAdding: .day, value: 1, to: today)!
let day8 = calendar.date(byAdding: .day, value: 8, to: today)!
let sharedStadium = makeStadium(city: "New York", coord: nycCoord)
let bridgeStadium = makeStadium(city: "Boston", coord: bostonCoord)
let anchorStart = makeGame(stadiumId: sharedStadium.id, date: day0)
let bridgeGame = makeGame(stadiumId: bridgeStadium.id, date: day1)
let anchorEnd = makeGame(stadiumId: sharedStadium.id, date: day8)
let routes = GameDAGRouter.findRoutes(
games: [anchorStart, bridgeGame, anchorEnd],
stadiums: [sharedStadium.id: sharedStadium, bridgeStadium.id: bridgeStadium],
constraints: constraints,
anchorGameIds: [anchorStart.id, anchorEnd.id]
)
#expect(!routes.isEmpty, "Expected a route that includes both anchors across an 8-day gap")
for route in routes {
let ids = Set(route.map { $0.id })
#expect(ids.contains(anchorStart.id), "Route should include start anchor")
#expect(ids.contains(anchorEnd.id), "Route should include end anchor")
}
}
// MARK: - Property Tests
@Test("Property: route count never exceeds maxOptions (75)")
func property_routeCountNeverExceedsMax() {
let today = calendar.startOfDay(for: Date())
// Create many games to stress test
var games: [Game] = []
var stadiums: [String: Stadium] = [:]
for dayOffset in 0..<10 {
let date = calendar.date(byAdding: .day, value: dayOffset, to: today)!
for (city, coord) in [("NYC", nycCoord), ("BOS", bostonCoord), ("PHI", phillyCoord)] {
let (game, stadium) = makeGameAndStadium(city: "\(city)_\(dayOffset)", date: date, coord: coord)
games.append(game)
stadiums[stadium.id] = stadium
}
}
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
#expect(routes.count <= 75, "Should never exceed 75 routes")
}
@Test("Property: all routes satisfy constraints")
func property_allRoutesSatisfyConstraints() {
let today = calendar.startOfDay(for: Date())
let dates = (0..<5).map { calendar.date(byAdding: .day, value: $0, to: today)! }
let gamesAndStadiums = [
makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord)
]
let games = gamesAndStadiums.map { $0.0 }
var stadiums: [String: Stadium] = [:]
for (_, stadium) in gamesAndStadiums {
stadiums[stadium.id] = stadium
}
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Every consecutive pair in every route should be feasible
for route in routes {
for i in 1..<route.count {
let from = route[i-1]
let to = route[i]
// Time must move forward
#expect(to.startTime > from.startTime, "Time must move forward")
// If different stadiums, distance must be reasonable
if from.stadiumId != to.stadiumId {
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
Issue.record("Missing stadium data")
continue
}
let distance = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
) * 1.3
let daysAvailable = calendar.dateComponents([.day], from: from.startTime, to: to.startTime).day ?? 1
let maxMiles = Double(max(1, daysAvailable)) * constraints.maxDailyDrivingHours * 60 // 60 mph
#expect(distance <= maxMiles * 1.5, "Distance should be achievable") // Allow some buffer
}
}
}
}
// MARK: - Edge Case Tests
@Test("Edge: games at same stadium always feasible")
func edge_sameStadium_alwaysFeasible() {
let today = calendar.startOfDay(for: Date())
let game1Time = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
let game2Time = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: today)! // Doubleheader
let stadium = makeStadium(city: "New York", coord: nycCoord)
let game1 = makeGame(stadiumId: stadium.id, date: game1Time)
let game2 = makeGame(stadiumId: stadium.id, date: game2Time)
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: [stadium.id: stadium],
constraints: constraints
)
let bothGamesRoute = routes.first(where: { $0.count == 2 })
#expect(bothGamesRoute != nil, "Same stadium games should always be feasible")
}
@Test("Edge: games out of order are sorted chronologically")
func edge_unsortedGames_areSorted() {
let today = calendar.startOfDay(for: Date())
let game1Date = calendar.date(byAdding: .day, value: 2, to: today)!
let game2Date = today
let game3Date = calendar.date(byAdding: .day, value: 1, to: today)!
// Pass games out of order
let (game1, stadium1) = makeGameAndStadium(city: "City1", date: game1Date, coord: nycCoord)
let (game2, stadium2) = makeGameAndStadium(city: "City2", date: game2Date, coord: phillyCoord)
let (game3, stadium3) = makeGameAndStadium(city: "City3", date: game3Date, coord: bostonCoord)
let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3]
let routes = GameDAGRouter.findRoutes(
games: [game1, game2, game3], // Out of order
stadiums: stadiums,
constraints: constraints
)
// All routes should be chronological
for route in routes {
for i in 1..<route.count {
#expect(route[i].startTime > route[i-1].startTime)
}
}
}
@Test("Edge: missing stadium for game is handled gracefully")
func edge_missingStadium_handledGracefully() {
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: Date(), coord: nycCoord)
let game2 = makeGame(stadiumId: "nonexistent-stadium", date: Date().addingTimeInterval(86400))
// Only provide stadium for game1
let stadiums = [stadium1.id: stadium1]
// Should not crash
let routes = GameDAGRouter.findRoutes(
games: [game1, game2],
stadiums: stadiums,
constraints: constraints
)
// May return routes with just game1, or empty
#expect(routes.allSatisfy { route in
route.allSatisfy { game in stadiums[game.stadiumId] != nil || game.id == game2.id }
})
}
// MARK: - Helper Methods
private func makeGameAndStadium(
city: String,
date: Date,
coord: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0)
) -> (Game, Stadium) {
let stadiumId = "stadium-\(UUID().uuidString)"
let stadium = Stadium(
id: stadiumId,
name: "\(city) Stadium",
city: city,
state: "XX",
latitude: coord.latitude,
longitude: coord.longitude,
capacity: 40000,
sport: .mlb,
timeZoneIdentifier: "America/New_York"
)
let game = Game(
id: "game-\(UUID().uuidString)",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: stadiumId,
dateTime: date,
sport: .mlb,
season: "2026",
isPlayoff: false
)
return (game, stadium)
}
private func makeStadium(
city: String,
coord: CLLocationCoordinate2D
) -> Stadium {
Stadium(
id: "stadium-\(UUID().uuidString)",
name: "\(city) Stadium",
city: city,
state: "XX",
latitude: coord.latitude,
longitude: coord.longitude,
capacity: 40000,
sport: .mlb,
timeZoneIdentifier: "America/New_York"
)
}
private func makeGame(
stadiumId: String,
date: Date
) -> Game {
Game(
id: "game-\(UUID().uuidString)",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: stadiumId,
dateTime: date,
sport: .mlb,
season: "2026",
isPlayoff: false
)
}
}