// // GameDAGRouterTests.swift // SportsTimeTests // // TDD edge case tests for GameDAGRouter. // Tests define correctness - code must match. // import Testing import Foundation import CoreLocation @testable import SportsTime @Suite("GameDAGRouter Edge Case Tests") struct GameDAGRouterTests { // MARK: - Test Helpers private func makeStadium( id: UUID = UUID(), city: String, lat: Double = 34.0, lon: Double = -118.0 ) -> Stadium { Stadium( id: id, name: "\(city) Stadium", city: city, state: "XX", latitude: lat, longitude: lon, capacity: 40000, sport: .mlb ) } private func makeGame( id: UUID = UUID(), stadiumId: UUID, startTime: Date ) -> Game { Game( id: id, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: stadiumId, dateTime: startTime, sport: .mlb, season: "2026" ) } private func date(_ string: String) -> Date { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm" formatter.timeZone = TimeZone(identifier: "America/Los_Angeles") return formatter.date(from: string)! } // MARK: - Standard Test Stadiums (spread across US) private var losAngelesStadium: Stadium { makeStadium(city: "Los Angeles", lat: 34.0739, lon: -118.2400) } private var sanFranciscoStadium: Stadium { makeStadium(city: "San Francisco", lat: 37.7786, lon: -122.3893) } private var newYorkStadium: Stadium { makeStadium(city: "New York", lat: 40.8296, lon: -73.9262) } private var chicagoStadium: Stadium { makeStadium(city: "Chicago", lat: 41.9484, lon: -87.6553) } // MARK: - Test 1: Empty games array returns empty routes @Test("Empty games array returns empty routes") func findRoutes_EmptyGames_ReturnsEmpty() { let result = GameDAGRouter.findRoutes( games: [], stadiums: [:], constraints: .default ) #expect(result.isEmpty) } // MARK: - Test 2: Single game returns that game @Test("Single game returns single-game route") func findRoutes_SingleGame_ReturnsSingleRoute() { let stadium = losAngelesStadium let game = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 19:00")) let result = GameDAGRouter.findRoutes( games: [game], stadiums: [stadium.id: stadium], constraints: .default ) #expect(result.count == 1) #expect(result.first?.count == 1) #expect(result.first?.first?.id == game.id) } // MARK: - Test 3: Single game with non-matching anchor returns empty @Test("Single game with non-matching anchor returns empty") func findRoutes_SingleGame_NonMatchingAnchor_ReturnsEmpty() { let stadium = losAngelesStadium let game = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 19:00")) let nonExistentAnchor = UUID() let result = GameDAGRouter.findRoutes( games: [game], stadiums: [stadium.id: stadium], constraints: .default, anchorGameIds: [nonExistentAnchor] ) #expect(result.isEmpty) } // MARK: - Test 4: Two games, chronological and feasible, returns route with both @Test("Two chronological feasible games returns combined route") func findRoutes_TwoGames_Chronological_Feasible_ReturnsCombined() { let la = losAngelesStadium let sf = sanFranciscoStadium // LA to SF is ~380 miles, ~6 hours drive // Games are 2 days apart - plenty of time let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 19:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-17 19:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sf.id: sf], constraints: .default ) // Should have route containing both games let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "Should have a route with both games") } // MARK: - Test 5: Two games, chronological but infeasible (too far), returns separate routes @Test("Two games too far apart same day returns separate routes") func findRoutes_TwoGames_TooFar_SameDay_ReturnsSeparate() { let la = losAngelesStadium let ny = newYorkStadium // LA to NY is ~2800 miles - impossible in same day let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: ny.id, startTime: date("2026-06-15 20:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, ny.id: ny], constraints: .default ) // Should return two separate single-game routes #expect(result.count == 2, "Should return 2 separate routes") let singleGameRoutes = result.filter { $0.count == 1 } #expect(singleGameRoutes.count == 2, "Both routes should have single game") } // MARK: - Test 6: Two games, reverse chronological, returns separate routes @Test("Two games reverse chronological returns separate routes") func findRoutes_TwoGames_ReverseChronological_ReturnsSeparate() { let la = losAngelesStadium let sf = sanFranciscoStadium // game2 starts BEFORE game1 let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-17 19:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-15 19:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sf.id: sf], constraints: .default ) // With no anchors, it should return the combined route (sorted chronologically) // OR separate routes if that's what the algorithm does // The key point is we get some result, not empty #expect(!result.isEmpty, "Should return at least one route") // Check if combined route exists (sorted game2 -> game1) let combined = result.first { route in route.count == 2 && route[0].id == game2.id && // SF game (earlier) route[1].id == game1.id // LA game (later) } if combined == nil { // If no combined route, should have separate single-game routes #expect(result.count >= 2, "Without combined route, should have separate routes") } } // MARK: - Test 7: Three games where only pairs are feasible @Test("Three games with only feasible pairs returns valid combinations") func findRoutes_ThreeGames_OnlyPairsFeasible_ReturnsValidCombinations() { let la = losAngelesStadium let sf = sanFranciscoStadium let ny = newYorkStadium // Day 1: LA game // Day 2: SF game (feasible from LA) // Day 2: NY game (NOT feasible from LA same day) let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-16 19:00")) let game3 = makeGame(stadiumId: ny.id, startTime: date("2026-06-16 20:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2, game3], stadiums: [la.id: la, sf.id: sf, ny.id: ny], constraints: .default ) // Should return routes: // - LA -> SF (feasible pair) // - LA alone // - SF alone // - NY alone // No LA -> NY same day (infeasible) #expect(!result.isEmpty, "Should return at least one route") // Verify no route has LA then NY on same day for route in result { let hasLA = route.contains { $0.stadiumId == la.id } let hasNY = route.contains { $0.stadiumId == ny.id } if hasLA && hasNY { // If both, they shouldn't be consecutive on same day let laIndex = route.firstIndex { $0.stadiumId == la.id }! let nyIndex = route.firstIndex { $0.stadiumId == ny.id }! if nyIndex == laIndex + 1 { let laGame = route[laIndex] let nyGame = route[nyIndex] let calendar = Calendar.current let sameDay = calendar.isDate(laGame.startTime, inSameDayAs: nyGame.startTime) #expect(!sameDay, "LA -> NY same day should not be feasible") } } } } // MARK: - Test 8: Anchor game filtering - routes missing anchors excluded @Test("Routes missing anchor games are excluded") func findRoutes_AnchorFiltering_ExcludesRoutesMissingAnchors() { let la = losAngelesStadium let sf = sanFranciscoStadium let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 19:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-17 19:00")) // Anchor on game2 - all routes must include it let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sf.id: sf], constraints: .default, anchorGameIds: [game2.id] ) // Every returned route must contain game2 for route in result { let containsAnchor = route.contains { $0.id == game2.id } #expect(containsAnchor, "Every route must contain anchor game") } } // MARK: - Test 9: Repeat cities OFF - routes with same city twice excluded @Test("Repeat cities OFF excludes routes visiting same city twice") func findRoutes_RepeatCitiesOff_ExcludesSameCityTwice() { let la1 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0739, lon: -118.2400) let la2 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0140, lon: -118.2879) let sf = sanFranciscoStadium // Two stadiums in LA (different venues), one in SF let game1 = makeGame(stadiumId: la1.id, startTime: date("2026-06-15 19:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-16 19:00")) let game3 = makeGame(stadiumId: la2.id, startTime: date("2026-06-17 19:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2, game3], stadiums: [la1.id: la1, la2.id: la2, sf.id: sf], constraints: .default, allowRepeatCities: false ) // No route should have both LA stadiums (same city) for route in result { let laCities = route.filter { game in [la1.id, la2.id].contains(game.stadiumId) }.count #expect(laCities <= 1, "With repeat cities OFF, can't visit LA twice") } } // MARK: - Test 10: Repeat cities ON - routes with same city twice included @Test("Repeat cities ON allows routes visiting same city twice") func findRoutes_RepeatCitiesOn_AllowsSameCityTwice() { let la1 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0739, lon: -118.2400) let la2 = makeStadium(id: UUID(), city: "Los Angeles", lat: 34.0140, lon: -118.2879) // Two games at different LA stadiums let game1 = makeGame(stadiumId: la1.id, startTime: date("2026-06-15 19:00")) let game2 = makeGame(stadiumId: la2.id, startTime: date("2026-06-17 19:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la1.id: la1, la2.id: la2], constraints: .default, allowRepeatCities: true ) // Should have a route with both games (both in LA) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "With repeat cities ON, should allow both LA games") } // MARK: - canTransition Boundary Tests (via findRoutes behavior) @Test("Same stadium same day 4 hours apart is feasible") func findRoutes_SameStadium_SameDay_4HoursApart_Feasible() { let stadium = losAngelesStadium // Same stadium, 4 hours apart - should be feasible let game1 = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: stadium.id, startTime: date("2026-06-15 20:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [stadium.id: stadium], constraints: .default ) // Should have a route with both games (same stadium is always feasible) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "Same stadium transition should be feasible") } @Test("Different stadium 1000 miles apart same day is infeasible") func findRoutes_DifferentStadium_1000Miles_SameDay_Infeasible() { // LA to Chicago is ~1750 miles, way too far for same day let la = losAngelesStadium let chicago = chicagoStadium // Same day, 6 hours apart - impossible to drive 1750 miles let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 13:00")) let game2 = makeGame(stadiumId: chicago.id, startTime: date("2026-06-15 20:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, chicago.id: chicago], constraints: .default ) // Should NOT have a combined route (too far for same day) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth == nil, "1750 miles same day should be infeasible") } @Test("Different stadium 1000 miles apart 2 days apart is feasible") func findRoutes_DifferentStadium_1000Miles_2DaysApart_Feasible() { // LA to Chicago is ~1750 miles, but 2 days gives 16 hours driving (at 60mph = 960 miles max) // Actually need more time - let's use 3 days for 1750 miles at 60mph = ~29 hours // 3 days * 8 hours/day = 24 hours driving - still not enough // Use LA to SF (~380 miles) which is doable in 1-2 days let la = losAngelesStadium let sf = sanFranciscoStadium // 2 days apart - 380 miles * 1.3 = 494 miles, at 60mph = 8.2 hours // 2 days * 8 hours = 16 hours available - feasible let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 19:00")) let game2 = makeGame(stadiumId: sf.id, startTime: date("2026-06-17 19:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sf.id: sf], constraints: .default ) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "380 miles with 2 days should be feasible") } @Test("Different stadium 100 miles apart 4 hours available is feasible") func findRoutes_DifferentStadium_100Miles_4HoursAvailable_Feasible() { // Create stadiums ~100 miles apart (roughly LA to San Diego distance) let la = losAngelesStadium let sanDiego = makeStadium(city: "San Diego", lat: 32.7076, lon: -117.1569) // LA to San Diego is ~120 miles * 1.3 = 156 road miles, at 60mph = 2.6 hours // Game 1 at 14:00, ends ~17:00 (3hr buffer), departure 17:00 // Game 2 at 21:00, must arrive by 20:00 (1hr buffer) // Available: 3 hours - just enough for 2.6 hour drive let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: sanDiego.id, startTime: date("2026-06-15 21:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sanDiego.id: sanDiego], constraints: .default ) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "~120 miles with 4 hours available should be feasible") } @Test("Different stadium 100 miles apart 1 hour available is infeasible") func findRoutes_DifferentStadium_100Miles_1HourAvailable_Infeasible() { let la = losAngelesStadium let sanDiego = makeStadium(city: "San Diego", lat: 32.7076, lon: -117.1569) // Game 1 at 14:00, ends ~17:00 (3hr buffer) // Game 2 at 18:00, must arrive by 17:00 (1hr buffer) // Available: 0 hours - not enough for any driving let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: sanDiego.id, startTime: date("2026-06-15 18:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sanDiego.id: sanDiego], constraints: .default ) // Should NOT have a combined route (not enough time) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth == nil, "~120 miles with no available time should be infeasible") } @Test("Game end buffer - 3 hour buffer after game end before departure") func findRoutes_GameEndBuffer_3Hours() { let la = losAngelesStadium let sanDiego = makeStadium(city: "San Diego", lat: 32.7076, lon: -117.1569) // Game 1 at 14:00, ends + 3hr buffer = departure 17:00 // LA to SD: ~113 miles * 1.3 = 147 road miles, at 60mph = 2.45 hours // Game 2 at 19:30 - arrival deadline 18:30 (1hr buffer) // Available: 1.5 hours (17:00 to 18:30) - clearly infeasible for 2.45 hour drive let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: sanDiego.id, startTime: date("2026-06-15 19:30")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sanDiego.id: sanDiego], constraints: .default ) // With 3hr game end buffer: depart 17:00, arrive by 18:30 = 1.5 hours // Need 2.45 hours driving - clearly infeasible let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth == nil, "Only 1.5 hours available for 2.45 hour drive should be infeasible") } @Test("Arrival buffer - 1 hour buffer before next game start") func findRoutes_ArrivalBuffer_1Hour() { let la = losAngelesStadium let sanDiego = makeStadium(city: "San Diego", lat: 32.7076, lon: -117.1569) // Game 1 at 14:00, ends + 3hr buffer = depart 17:00 // Need ~2.6 hours driving // Game 2 at 22:00 - arrival deadline 21:00 // Available: 4 hours (17:00 to 21:00) - feasible let game1 = makeGame(stadiumId: la.id, startTime: date("2026-06-15 14:00")) let game2 = makeGame(stadiumId: sanDiego.id, startTime: date("2026-06-15 22:00")) let result = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: [la.id: la, sanDiego.id: sanDiego], constraints: .default ) let routeWithBoth = result.first { route in route.count == 2 && route.contains { $0.id == game1.id } && route.contains { $0.id == game2.id } } #expect(routeWithBoth != nil, "4 hours available (with 1hr arrival buffer) should be feasible") } }