// // 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 = TestClock.calendar // 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: TestClock.now) 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: TestClock.now) 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: TestClock.now) 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: TestClock.now) let game1Date = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: today)! let game2Date = calendar.date(bySettingHour: 20, minute: 0, second: 0, of: today)! // NYC to Philly is ~95 miles, ~1.5 hours - feasible same day // With ~3h game duration + 2h post-game buffer, departure ~17:00, arrival ~18:30, game at 20:00 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: TestClock.now) 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: TestClock.now) 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: TestClock.now) 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 = [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: TestClock.now) 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: TestClock.now) 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, _) = 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: TestClock.now) 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[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: TestClock.now) 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: TestClock.now) 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: TestClock.now) 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: TestClock.now) // 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: TestClock.now) 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.. 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: TestClock.now) 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: TestClock.now) 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[i-1].startTime) } } } @Test("Edge: missing stadium for game is handled gracefully") func edge_missingStadium_handledGracefully() { let (game1, stadium1) = makeGameAndStadium(city: "New York", date: TestClock.now, coord: nycCoord) let game2 = makeGame(stadiumId: "nonexistent-stadium", date: TestClock.now.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 ) } }