This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
795 lines
33 KiB
Swift
795 lines
33 KiB
Swift
//
|
|
// GameDAGRouterTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Phase 2: GameDAGRouter Tests
|
|
// The "scary to touch" component — extensive edge case coverage.
|
|
//
|
|
|
|
import Testing
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
@Suite("GameDAGRouter Tests")
|
|
struct GameDAGRouterTests {
|
|
|
|
// MARK: - Test Fixtures
|
|
|
|
private let calendar = Calendar.current
|
|
|
|
// Standard game times (7pm local)
|
|
private func gameDate(daysFromNow: Int, hour: Int = 19) -> Date {
|
|
let baseDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 1))!
|
|
var components = calendar.dateComponents([.year, .month, .day], from: baseDate)
|
|
components.day! += daysFromNow
|
|
components.hour = hour
|
|
components.minute = 0
|
|
return calendar.date(from: components)!
|
|
}
|
|
|
|
// Create a stadium at a known location
|
|
private func makeStadium(
|
|
id: String = "stadium_test_\(UUID().uuidString)",
|
|
city: String,
|
|
lat: Double,
|
|
lon: Double
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: "\(city) Stadium",
|
|
city: city,
|
|
state: "ST",
|
|
latitude: lat,
|
|
longitude: lon,
|
|
capacity: 40000,
|
|
sport: .mlb
|
|
)
|
|
}
|
|
|
|
// Create a game at a stadium
|
|
private func makeGame(
|
|
id: String = "game_test_\(UUID().uuidString)",
|
|
stadiumId: String,
|
|
dateTime: Date
|
|
) -> Game {
|
|
Game(
|
|
id: id,
|
|
homeTeamId: "team_test_\(UUID().uuidString)",
|
|
awayTeamId: "team_test_\(UUID().uuidString)",
|
|
stadiumId: stadiumId,
|
|
dateTime: dateTime,
|
|
sport: .mlb,
|
|
season: "2026"
|
|
)
|
|
}
|
|
|
|
// MARK: - 2A: Empty & Single-Element Cases
|
|
|
|
@Test("2.1 - Empty games returns empty array")
|
|
func test_findRoutes_EmptyGames_ReturnsEmptyArray() {
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [],
|
|
stadiums: [:],
|
|
constraints: .default
|
|
)
|
|
|
|
#expect(routes.isEmpty, "Expected empty array for empty games input")
|
|
}
|
|
|
|
@Test("2.2 - Single game returns single route")
|
|
func test_findRoutes_SingleGame_ReturnsSingleRoute() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let game = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game],
|
|
stadiums: [stadiumId: stadium],
|
|
constraints: .default
|
|
)
|
|
|
|
#expect(routes.count == 1, "Expected exactly 1 route for single game")
|
|
#expect(routes.first?.count == 1, "Route should contain exactly 1 game")
|
|
#expect(routes.first?.first?.id == game.id, "Route should contain the input game")
|
|
}
|
|
|
|
@Test("2.3 - Single game with matching anchor returns single route")
|
|
func test_findRoutes_SingleGame_WithMatchingAnchor_ReturnsSingleRoute() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let game = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game],
|
|
stadiums: [stadiumId: stadium],
|
|
constraints: .default,
|
|
anchorGameIds: [game.id]
|
|
)
|
|
|
|
#expect(routes.count == 1, "Expected 1 route when anchor matches the only game")
|
|
#expect(routes.first?.contains(where: { $0.id == game.id }) == true)
|
|
}
|
|
|
|
@Test("2.4 - Single game with non-matching anchor returns empty")
|
|
func test_findRoutes_SingleGame_WithNonMatchingAnchor_ReturnsEmpty() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let game = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let nonExistentAnchor = "stadium_nonexistent_\(UUID().uuidString)"
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game],
|
|
stadiums: [stadiumId: stadium],
|
|
constraints: .default,
|
|
anchorGameIds: [nonExistentAnchor]
|
|
)
|
|
|
|
#expect(routes.isEmpty, "Expected empty when anchor doesn't match any game")
|
|
}
|
|
|
|
// MARK: - 2B: Two-Game Cases
|
|
|
|
@Test("2.5 - Two games with feasible transition returns both in order")
|
|
func test_findRoutes_TwoGames_FeasibleTransition_ReturnsBothInOrder() {
|
|
// Chicago to Milwaukee is ~90 miles - easily feasible
|
|
let chicagoStadiumId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeStadiumId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
|
|
let chicagoStadium = makeStadium(id: chicagoStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukeeStadium = makeStadium(id: milwaukeeStadiumId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
|
|
let game1 = makeGame(stadiumId: chicagoStadiumId, dateTime: gameDate(daysFromNow: 1, hour: 14)) // Day 1, 2pm
|
|
let game2 = makeGame(stadiumId: milwaukeeStadiumId, dateTime: gameDate(daysFromNow: 2, hour: 19)) // Day 2, 7pm
|
|
|
|
let stadiums = [chicagoStadiumId: chicagoStadium, milwaukeeStadiumId: milwaukeeStadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should have at least one route with both games
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Expected a route containing both games")
|
|
|
|
if let route = routeWithBoth {
|
|
#expect(route[0].id == game1.id, "First game should be Chicago (earlier)")
|
|
#expect(route[1].id == game2.id, "Second game should be Milwaukee (later)")
|
|
}
|
|
}
|
|
|
|
@Test("2.6 - Two games with infeasible transition returns separate routes")
|
|
func test_findRoutes_TwoGames_InfeasibleTransition_ReturnsSeparateRoutes() {
|
|
// NYC to LA on same day is infeasible
|
|
let nycStadiumId = "stadium_nyc_\(UUID().uuidString)"
|
|
let laStadiumId = "stadium_la_\(UUID().uuidString)"
|
|
|
|
let nycStadium = makeStadium(id: nycStadiumId, city: "New York", lat: 40.7128, lon: -73.9352)
|
|
let laStadium = makeStadium(id: laStadiumId, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
|
|
|
|
// Games on same day, 5 hours apart (can't drive 2500 miles in 5 hours)
|
|
let game1 = makeGame(stadiumId: nycStadiumId, dateTime: gameDate(daysFromNow: 1, hour: 13)) // 1pm
|
|
let game2 = makeGame(stadiumId: laStadiumId, dateTime: gameDate(daysFromNow: 1, hour: 21)) // 9pm
|
|
|
|
let stadiums = [nycStadiumId: nycStadium, laStadiumId: laStadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should NOT have a route with both games (infeasible)
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth == nil, "Should not have a combined route for infeasible transition")
|
|
|
|
// Should have separate single-game routes
|
|
let singleGameRoutes = routes.filter { $0.count == 1 }
|
|
#expect(singleGameRoutes.count >= 2, "Should have separate routes for each game")
|
|
}
|
|
|
|
@Test("2.7 - Two games same stadium same day (doubleheader) succeeds")
|
|
func test_findRoutes_TwoGames_SameStadiumSameDay_Succeeds() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Doubleheader: 1pm and 7pm same day, same stadium
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1, hour: 13))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1, hour: 19))
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should have a route with both games
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Doubleheader at same stadium should be feasible")
|
|
|
|
if let route = routeWithBoth {
|
|
#expect(route[0].startTime < route[1].startTime, "Games should be in chronological order")
|
|
}
|
|
}
|
|
|
|
// MARK: - 2C: Anchor Game Constraints
|
|
|
|
@Test("2.8 - With anchors only returns routes containing all anchors")
|
|
func test_findRoutes_WithAnchors_OnlyReturnsRoutesContainingAllAnchors() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 2))
|
|
let game3 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 3))
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
let anchor = game2.id
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2, game3],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
anchorGameIds: [anchor]
|
|
)
|
|
|
|
// All routes must contain the anchor game
|
|
for route in routes {
|
|
let containsAnchor = route.contains { $0.id == anchor }
|
|
#expect(containsAnchor, "Every route must contain the anchor game")
|
|
}
|
|
}
|
|
|
|
@Test("2.9 - Impossible anchors returns empty")
|
|
func test_findRoutes_ImpossibleAnchors_ReturnsEmpty() {
|
|
// Two anchors at opposite ends of country on same day - impossible to attend both
|
|
let nycStadiumId = "stadium_nyc_\(UUID().uuidString)"
|
|
let laStadiumId = "stadium_la_\(UUID().uuidString)"
|
|
|
|
let nycStadium = makeStadium(id: nycStadiumId, city: "New York", lat: 40.7128, lon: -73.9352)
|
|
let laStadium = makeStadium(id: laStadiumId, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
|
|
|
|
// Same day, same time - physically impossible
|
|
let game1 = makeGame(stadiumId: nycStadiumId, dateTime: gameDate(daysFromNow: 1, hour: 19))
|
|
let game2 = makeGame(stadiumId: laStadiumId, dateTime: gameDate(daysFromNow: 1, hour: 19))
|
|
|
|
let stadiums = [nycStadiumId: nycStadium, laStadiumId: laStadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
anchorGameIds: [game1.id, game2.id] // Both are anchors
|
|
)
|
|
|
|
#expect(routes.isEmpty, "Should return empty for impossible anchor combination")
|
|
}
|
|
|
|
@Test("2.10 - Multiple anchors route must contain all")
|
|
func test_findRoutes_MultipleAnchors_RouteMustContainAll() {
|
|
// Three games in nearby cities over 3 days - all feasible
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
let detroitId = "stadium_detroit_\(UUID().uuidString)"
|
|
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
|
|
|
let game1 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 2))
|
|
let game3 = makeGame(stadiumId: detroitId, dateTime: gameDate(daysFromNow: 3))
|
|
|
|
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit]
|
|
|
|
// Make game1 and game3 anchors
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2, game3],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
anchorGameIds: [game1.id, game3.id]
|
|
)
|
|
|
|
#expect(!routes.isEmpty, "Should find routes with both anchors")
|
|
|
|
for route in routes {
|
|
let hasGame1 = route.contains { $0.id == game1.id }
|
|
let hasGame3 = route.contains { $0.id == game3.id }
|
|
#expect(hasGame1 && hasGame3, "Every route must contain both anchor games")
|
|
}
|
|
}
|
|
|
|
// MARK: - 2D: Repeat Cities Toggle
|
|
|
|
@Test("2.11 - Allow repeat cities same city multiple days allowed")
|
|
func test_findRoutes_AllowRepeatCities_SameCityMultipleDays_Allowed() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Three games in Chicago over 3 days
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 2))
|
|
let game3 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 3))
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2, game3],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: true
|
|
)
|
|
|
|
// Should have routes with all 3 games (same city allowed)
|
|
let routeWithAll = routes.first { $0.count == 3 }
|
|
#expect(routeWithAll != nil, "Should allow visiting same city multiple days when repeat cities enabled")
|
|
}
|
|
|
|
@Test("2.12 - Disallow repeat cities skips second visit")
|
|
func test_findRoutes_DisallowRepeatCities_SkipsSecondVisit() {
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
|
|
// Day 1: Chicago, Day 2: Milwaukee, Day 3: Back to Chicago
|
|
let game1 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 2))
|
|
let game3 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 3)) // Return to Chicago
|
|
|
|
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2, game3],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: false
|
|
)
|
|
|
|
// Should NOT have a route with both Chicago games
|
|
for route in routes {
|
|
let chicagoGames = route.filter { stadiums[$0.stadiumId]?.city == "Chicago" }
|
|
#expect(chicagoGames.count <= 1, "Should not repeat Chicago when repeat cities disabled")
|
|
}
|
|
}
|
|
|
|
@Test("2.13 - Disallow repeat cities only option is repeat overrides with warning")
|
|
func test_findRoutes_DisallowRepeatCities_OnlyOptionIsRepeat_OverridesWithWarning() {
|
|
// When only games available are in the same city, we still need to produce routes
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Only Chicago games available
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 2))
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: false
|
|
)
|
|
|
|
// Should still return single-game routes even with repeat cities disabled
|
|
#expect(!routes.isEmpty, "Should return routes even when only option is repeat city")
|
|
|
|
// Note: TDD defines Trip.warnings property (test 2.13 in plan)
|
|
// For now, we verify routes exist; warning system will be added when implementing
|
|
}
|
|
|
|
// MARK: - 2E: Driving Constraints
|
|
|
|
@Test("2.14 - Exceeds max daily driving transition rejected")
|
|
func test_findRoutes_ExceedsMaxDailyDriving_TransitionRejected() {
|
|
// NYC to Denver is ~1,800 miles, way over 8 hours of driving (480 miles at 60mph)
|
|
let nycId = "stadium_nyc_\(UUID().uuidString)"
|
|
let denverId = "stadium_denver_\(UUID().uuidString)"
|
|
|
|
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
|
|
let denver = makeStadium(id: denverId, city: "Denver", lat: 39.7392, lon: -104.9903)
|
|
|
|
// Games on consecutive days - can't drive 1800 miles in one day
|
|
let game1 = makeGame(stadiumId: nycId, dateTime: gameDate(daysFromNow: 1, hour: 14))
|
|
let game2 = makeGame(stadiumId: denverId, dateTime: gameDate(daysFromNow: 2, hour: 19))
|
|
|
|
let stadiums = [nycId: nyc, denverId: denver]
|
|
|
|
// Use strict constraints (8 hours max)
|
|
let strictConstraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: strictConstraints
|
|
)
|
|
|
|
// Should not have a combined route (distance too far for 1 day)
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth == nil, "Should reject transition exceeding max daily driving")
|
|
}
|
|
|
|
@Test("2.15 - Multi-day drive allowed if within daily limits")
|
|
func test_findRoutes_MultiDayDrive_Allowed_IfWithinDailyLimits() {
|
|
// NYC to Chicago is ~790 miles - doable over multiple days
|
|
let nycId = "stadium_nyc_\(UUID().uuidString)"
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
|
|
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Games 3 days apart - enough time to drive 790 miles
|
|
let game1 = makeGame(stadiumId: nycId, dateTime: gameDate(daysFromNow: 1, hour: 14))
|
|
let game2 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 4, hour: 19))
|
|
|
|
let stadiums = [nycId: nyc, chicagoId: chicago]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should have a route with both (multi-day driving allowed)
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Should allow multi-day drive when time permits")
|
|
}
|
|
|
|
@Test("2.16 - Same day different stadiums checks available time")
|
|
func test_findRoutes_SameDayDifferentStadiums_ChecksAvailableTime() {
|
|
// Chicago to Milwaukee is ~90 miles (~1.5 hours driving)
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
|
|
// Same day: Chicago at 1pm, Milwaukee at 7pm (6 hours apart - feasible)
|
|
let game1 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 1, hour: 13))
|
|
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 1, hour: 19))
|
|
|
|
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should be feasible (1pm game + 3hr duration + 1.5hr drive = arrives ~5:30pm for 7pm game)
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Should allow same-day travel when time permits")
|
|
|
|
// Now test too tight timing
|
|
let game3 = makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 2, hour: 16)) // 4pm
|
|
let game4 = makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 2, hour: 17)) // 5pm (only 1 hr apart)
|
|
|
|
let routes2 = GameDAGRouter.findRoutes(
|
|
games: [game3, game4],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
let tooTightRoute = routes2.first { $0.count == 2 }
|
|
#expect(tooTightRoute == nil, "Should reject same-day travel when not enough time")
|
|
}
|
|
|
|
// MARK: - 2F: Calendar Day Logic
|
|
|
|
@Test("2.17 - Max day lookahead respects limit")
|
|
func test_findRoutes_MaxDayLookahead_RespectsLimit() {
|
|
// Games more than 5 days apart should not connect directly
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 8)) // 7 days later
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// With max lookahead of 5, these shouldn't directly connect
|
|
// (Though they might still appear in separate routes)
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
|
|
// Note: Implementation uses maxDayLookahead = 5
|
|
// Games 7 days apart may not connect directly
|
|
// This test verifies the behavior
|
|
if routeWithBoth != nil {
|
|
// If they do connect, verify they're in order
|
|
#expect(routeWithBoth![0].startTime < routeWithBoth![1].startTime)
|
|
}
|
|
}
|
|
|
|
@Test("2.18 - DST transition handles correctly")
|
|
func test_findRoutes_DSTTransition_HandlesCorrectly() {
|
|
// Test around DST transition (March 9, 2026 - spring forward)
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Create dates around DST transition
|
|
var components1 = DateComponents()
|
|
components1.year = 2026
|
|
components1.month = 3
|
|
components1.day = 8 // Day before spring forward
|
|
components1.hour = 19
|
|
let preDST = calendar.date(from: components1)!
|
|
|
|
var components2 = DateComponents()
|
|
components2.year = 2026
|
|
components2.month = 3
|
|
components2.day = 9 // Spring forward day
|
|
components2.hour = 19
|
|
let postDST = calendar.date(from: components2)!
|
|
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: preDST)
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: postDST)
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should handle DST correctly - both games should be connectable
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Should handle DST transition correctly")
|
|
}
|
|
|
|
@Test("2.19 - Midnight game assigns to correct day")
|
|
func test_findRoutes_MidnightGame_AssignsToCorrectDay() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
// Game at 12:05 AM belongs to the new day
|
|
var components = DateComponents()
|
|
components.year = 2026
|
|
components.month = 6
|
|
components.day = 2
|
|
components.hour = 0
|
|
components.minute = 5
|
|
let midnightGame = calendar.date(from: components)!
|
|
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1, hour: 19)) // Day 1, 7pm
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: midnightGame) // Day 2, 12:05am
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Midnight game should be on day 2, making transition feasible
|
|
let routeWithBoth = routes.first { $0.count == 2 }
|
|
#expect(routeWithBoth != nil, "Midnight game should be assigned to correct calendar day")
|
|
}
|
|
|
|
// MARK: - 2G: Diversity Selection
|
|
|
|
@Test("2.20 - Select diverse routes includes short and long trips")
|
|
func test_selectDiverseRoutes_ShortAndLongTrips_BothRepresented() {
|
|
// Create a mix of games over a week
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
var games: [Game] = []
|
|
for day in 1...7 {
|
|
games.append(makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: day)))
|
|
}
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: true
|
|
)
|
|
|
|
// Should have both short (2-3 game) and long (5+ game) routes
|
|
let shortRoutes = routes.filter { $0.count <= 3 }
|
|
let longRoutes = routes.filter { $0.count >= 5 }
|
|
|
|
#expect(!shortRoutes.isEmpty, "Should include short trip options")
|
|
#expect(!longRoutes.isEmpty, "Should include long trip options")
|
|
}
|
|
|
|
@Test("2.21 - Select diverse routes includes high and low mileage")
|
|
func test_selectDiverseRoutes_HighAndLowMileage_BothRepresented() {
|
|
// Create games in both nearby and distant cities
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
let laId = "stadium_la_\(UUID().uuidString)"
|
|
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
let la = makeStadium(id: laId, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
|
|
|
|
let games = [
|
|
makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 1)),
|
|
makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 2)),
|
|
makeGame(stadiumId: laId, dateTime: gameDate(daysFromNow: 8)), // Far away, needs time
|
|
]
|
|
|
|
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, laId: la]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should have routes with varying mileage
|
|
#expect(!routes.isEmpty, "Should produce diverse mileage routes")
|
|
}
|
|
|
|
@Test("2.22 - Select diverse routes includes few and many cities")
|
|
func test_selectDiverseRoutes_FewAndManyCities_BothRepresented() {
|
|
// Create games across multiple cities
|
|
let cities = [
|
|
("Chicago", 41.8781, -87.6298),
|
|
("Milwaukee", 43.0389, -87.9065),
|
|
("Detroit", 42.3314, -83.0458),
|
|
("Cleveland", 41.4993, -81.6944),
|
|
]
|
|
|
|
var stadiums: [String: Stadium] = [:]
|
|
var games: [Game] = []
|
|
|
|
for (index, city) in cities.enumerated() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
stadiums[stadiumId] = makeStadium(id: stadiumId, city: city.0, lat: city.1, lon: city.2)
|
|
games.append(makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: index + 1)))
|
|
}
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should have routes with varying city counts
|
|
let cityCounts = routes.map { route in
|
|
Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
|
|
}
|
|
|
|
let minCities = cityCounts.min() ?? 0
|
|
let maxCities = cityCounts.max() ?? 0
|
|
|
|
#expect(minCities < maxCities || routes.count <= 1, "Should have routes with varying city counts")
|
|
}
|
|
|
|
@Test("2.23 - Select diverse routes deduplicates")
|
|
func test_selectDiverseRoutes_DuplicateRoutes_Deduplicated() {
|
|
let stadiumId = "stadium_test_\(UUID().uuidString)"
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
|
|
let game1 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 1))
|
|
let game2 = makeGame(stadiumId: stadiumId, dateTime: gameDate(daysFromNow: 2))
|
|
|
|
let stadiums = [stadiumId: stadium]
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game1, game2],
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: true
|
|
)
|
|
|
|
// Check for duplicates
|
|
var seen = Set<String>()
|
|
for route in routes {
|
|
let key = route.map { $0.id }.joined(separator: "-")
|
|
#expect(!seen.contains(key), "Routes should be deduplicated")
|
|
seen.insert(key)
|
|
}
|
|
}
|
|
|
|
// MARK: - 2H: Cycle Handling
|
|
|
|
@Test("2.24 - Graph with potential cycle handles silently")
|
|
func test_findRoutes_GraphWithPotentialCycle_HandlesSilently() {
|
|
// Create a scenario where a naive algorithm might get stuck in a loop
|
|
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
|
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
|
let detroitId = "stadium_detroit_\(UUID().uuidString)"
|
|
|
|
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
|
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
|
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
|
|
|
// Multiple games at each city over several days (potential for cycles)
|
|
let games = [
|
|
makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 1)),
|
|
makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 2)),
|
|
makeGame(stadiumId: chicagoId, dateTime: gameDate(daysFromNow: 3)), // Back to Chicago
|
|
makeGame(stadiumId: detroitId, dateTime: gameDate(daysFromNow: 4)),
|
|
makeGame(stadiumId: milwaukeeId, dateTime: gameDate(daysFromNow: 5)), // Back to Milwaukee
|
|
]
|
|
|
|
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit]
|
|
|
|
// Should complete without hanging or infinite loop
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
constraints: .default,
|
|
allowRepeatCities: true
|
|
)
|
|
|
|
// Just verify it completes and returns valid routes
|
|
#expect(routes.allSatisfy { !$0.isEmpty }, "All routes should be non-empty")
|
|
|
|
// Verify chronological order in each route
|
|
for route in routes {
|
|
for i in 0..<(route.count - 1) {
|
|
#expect(route[i].startTime < route[i + 1].startTime, "Games should be in chronological order")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 2I: Beam Search Behavior
|
|
|
|
@Test("2.25 - Large dataset scales beam width")
|
|
func test_findRoutes_LargeDataset_ScalesBeamWidth() {
|
|
// Generate a large dataset (use fixture generator)
|
|
let data = FixtureGenerator.generate(with: .medium) // 500 games
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: data.games,
|
|
stadiums: data.stadiumsById,
|
|
constraints: .default
|
|
)
|
|
|
|
// Should complete and return routes
|
|
#expect(!routes.isEmpty, "Should produce routes for large dataset")
|
|
|
|
// Verify routes are valid
|
|
for route in routes {
|
|
for i in 0..<(route.count - 1) {
|
|
#expect(route[i].startTime <= route[i + 1].startTime, "Games should be chronologically ordered")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("2.26 - Early termination triggers when beam full")
|
|
func test_findRoutes_EarlyTermination_TriggersWhenBeamFull() {
|
|
// Generate a dataset that would take very long without early termination
|
|
let config = FixtureGenerator.Configuration(
|
|
seed: 42,
|
|
gameCount: 100,
|
|
stadiumCount: 20,
|
|
teamCount: 20,
|
|
geographicSpread: .regional
|
|
)
|
|
let data = FixtureGenerator.generate(with: config)
|
|
|
|
let startTime = Date()
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: data.games,
|
|
stadiums: data.stadiumsById,
|
|
constraints: .default,
|
|
beamWidth: 50 // Moderate beam width
|
|
)
|
|
let elapsed = Date().timeIntervalSince(startTime)
|
|
|
|
// Should complete in reasonable time (< 30 seconds indicates early termination is working)
|
|
#expect(elapsed < TestConstants.hangTimeout, "Should complete before hang timeout (early termination)")
|
|
#expect(!routes.isEmpty, "Should produce routes")
|
|
}
|
|
}
|