// // 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 ) // Multi-game routes should not include games with missing stadiums // (the router can't build transitions without stadium coordinates). // Single-game routes may still include them since no transition is needed. for route in routes where route.count > 1 { for game in route { #expect(stadiums[game.stadiumId] != nil, "Multi-game route should not include games with missing stadiums") } } // The router should still return routes (at least the valid single-game route) #expect(!routes.isEmpty, "Should return at least the valid game as a single-game route") } // MARK: - Route Preference Tests @Test("routePreference: direct prefers lower mileage routes") func routePreference_direct_prefersLowerMileageRoutes() { // Create games spread across cities at varying distances let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) // Create games: nearby (NYC, Boston, Philly) and far (Chicago, LA) let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)! let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord) let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)! let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord) let game5Date = calendar.date(byAdding: .day, value: 5, to: baseDate)! let (game5, stadium5) = makeGameAndStadium(city: "Los Angeles", date: game5Date, coord: laCoord) let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4, stadium5.id: stadium5] let games = [game1, game2, game3, game4, game5] let directRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .direct ) let scenicRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .scenic ) // Direct routes should exist #expect(!directRoutes.isEmpty) #expect(!scenicRoutes.isEmpty) // Compare the first route from each: direct should have lower or equal total miles if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first { let directMiles = totalMiles(for: directFirst, stadiums: stadiums) let scenicMiles = totalMiles(for: scenicFirst, stadiums: stadiums) // Direct should tend toward lower mileage routes being ranked first #expect(directMiles <= scenicMiles, "Direct first route (\(Int(directMiles))mi) should be <= scenic first route (\(Int(scenicMiles))mi)") } } @Test("routePreference: scenic prefers more cities") func routePreference_scenic_prefersMoreCitiesRoutes() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) let game2Date = calendar.date(byAdding: .day, value: 1, to: baseDate)! let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) let game3Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! let (game3, stadium3) = makeGameAndStadium(city: "Philadelphia", date: game3Date, coord: phillyCoord) let game4Date = calendar.date(byAdding: .day, value: 3, to: baseDate)! let (game4, stadium4) = makeGameAndStadium(city: "Chicago", date: game4Date, coord: chicagoCoord) let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3, stadium4.id: stadium4] let games = [game1, game2, game3, game4] let scenicRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .scenic ) #expect(!scenicRoutes.isEmpty) // Scenic routes should have routes with multiple cities let maxCities = scenicRoutes.map { route in Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count }.max() ?? 0 #expect(maxCities >= 2, "Scenic should produce multi-city routes") let directRoutes2 = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .direct ) if let sFirst = scenicRoutes.first, let dFirst = directRoutes2.first { let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count #expect(sCities >= dCities, "Scenic first route should have >= cities than direct first route") } } @Test("routePreference: balanced matches default behavior") func routePreference_balanced_matchesDefault() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) let (game1, stadium1) = makeGameAndStadium(city: "New York", date: baseDate, coord: nycCoord) let game2Date = calendar.date(byAdding: .day, value: 2, to: baseDate)! let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: game2Date, coord: bostonCoord) let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2] let games = [game1, game2] let balancedRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .balanced ) let defaultRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints ) // Both should produce the same routes (balanced is default) #expect(balancedRoutes.count == defaultRoutes.count) if let bFirst = balancedRoutes.first, let dFirst = defaultRoutes.first { let bIds = bFirst.map { $0.id } let dIds = dFirst.map { $0.id } #expect(bIds == dIds, "Balanced and default should produce identical first route") } } // MARK: - Route Preference Scoring Tests @Test("routePreference: direct ranks lowest-mileage routes first overall") func routePreference_direct_ranksLowestMileageFirst() { // Create a spread of games across East Coast + distant cities // With enough games, the router produces diverse routes. // Direct should surface low-mileage routes at the top. let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) var games: [Game] = [] var stadiums: [String: Stadium] = [:] let cityData: [(String, CLLocationCoordinate2D)] = [ ("New York", nycCoord), ("Philadelphia", phillyCoord), ("Boston", bostonCoord), ("Chicago", chicagoCoord), ("Seattle", seattleCoord), ] for (dayOffset, (city, coord)) in cityData.enumerated() { let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)! let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) games.append(game) stadiums[stadium.id] = stadium } let directRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .direct ) let scenicRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .scenic ) #expect(!directRoutes.isEmpty) #expect(!scenicRoutes.isEmpty) // Direct first route should have <= miles than scenic first route if let dFirst = directRoutes.first, let sFirst = scenicRoutes.first { let dMiles = totalMiles(for: dFirst, stadiums: stadiums) let sMiles = totalMiles(for: sFirst, stadiums: stadiums) #expect(dMiles <= sMiles, "Direct first route (\(Int(dMiles))mi) should be <= scenic first route (\(Int(sMiles))mi)") } } @Test("routePreference: scenic ranks more-cities routes first overall") func routePreference_scenic_ranksMoreCitiesFirst() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) var games: [Game] = [] var stadiums: [String: Stadium] = [:] let cityData: [(String, CLLocationCoordinate2D)] = [ ("New York", nycCoord), ("Philadelphia", phillyCoord), ("Boston", bostonCoord), ("Chicago", chicagoCoord), ("Seattle", seattleCoord), ] for (dayOffset, (city, coord)) in cityData.enumerated() { let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate)! let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) games.append(game) stadiums[stadium.id] = stadium } let scenicRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .scenic ) let directRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .direct ) #expect(!scenicRoutes.isEmpty) #expect(!directRoutes.isEmpty) // Scenic first route should have >= cities than direct first route if let sFirst = scenicRoutes.first, let dFirst = directRoutes.first { let sCities = Set(sFirst.compactMap { stadiums[$0.stadiumId]?.city }).count let dCities = Set(dFirst.compactMap { stadiums[$0.stadiumId]?.city }).count #expect(sCities >= dCities, "Scenic first route (\(sCities) cities) should be >= direct first route (\(dCities) cities)") } } @Test("routePreference: different preferences produce different route ordering") func routePreference_differentPreferences_produceDifferentOrdering() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) var games: [Game] = [] var stadiums: [String: Stadium] = [:] // Create enough games across varied distances to force diverse options let cityData: [(String, CLLocationCoordinate2D)] = [ ("New York", nycCoord), ("Philadelphia", phillyCoord), ("Boston", bostonCoord), ("Chicago", chicagoCoord), ("Los Angeles", laCoord), ] for (dayOffset, (city, coord)) in cityData.enumerated() { let date = calendar.date(byAdding: .day, value: dayOffset * 2, to: baseDate)! let (game, stadium) = makeGameAndStadium(city: city, date: date, coord: coord) games.append(game) stadiums[stadium.id] = stadium } let directRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .direct ) let scenicRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .scenic ) let balancedRoutes = GameDAGRouter.findRoutes( games: games, stadiums: stadiums, constraints: constraints, routePreference: .balanced ) // All three should produce routes #expect(!directRoutes.isEmpty) #expect(!scenicRoutes.isEmpty) #expect(!balancedRoutes.isEmpty) // With enough variety, at least two of the three should differ in first-route let dKey = directRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" let sKey = scenicRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" let bKey = balancedRoutes.first.map { $0.map { $0.id }.joined(separator: "-") } ?? "" // With enough routes, average mileage should differ by preference // Direct should have lower average mileage in top routes than scenic if directRoutes.count >= 2 && scenicRoutes.count >= 2 { let directAvgMiles = directRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, directRoutes.count)) let scenicAvgMiles = scenicRoutes.prefix(3).map { totalMiles(for: $0, stadiums: stadiums) }.reduce(0, +) / Double(min(3, scenicRoutes.count)) #expect(directAvgMiles <= scenicAvgMiles, "Direct top routes (\(Int(directAvgMiles))mi avg) should have <= mileage than scenic (\(Int(scenicAvgMiles))mi avg)") } } // 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 ) } private func totalMiles(for route: [Game], stadiums: [String: Stadium]) -> Double { var total: Double = 0 for i in 0..<(route.count - 1) { guard let from = stadiums[route[i].stadiumId], let to = stadiums[route[i+1].stadiumId] else { continue } total += TravelEstimator.haversineDistanceMiles( from: from.coordinate, to: to.coordinate ) * 1.3 } return total } }