diff --git a/SportsTimeTests/Fixtures/FixtureGenerator.swift b/SportsTimeTests/Fixtures/FixtureGenerator.swift new file mode 100644 index 0000000..baea180 --- /dev/null +++ b/SportsTimeTests/Fixtures/FixtureGenerator.swift @@ -0,0 +1,488 @@ +// +// FixtureGenerator.swift +// SportsTimeTests +// +// Generates synthetic test data for unit and integration tests. +// Uses deterministic seeding for reproducible test results. +// + +import Foundation +import CoreLocation +@testable import SportsTime + +// MARK: - Random Number Generator with Seed + +struct SeededRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + self.state = seed + } + + mutating func next() -> UInt64 { + // xorshift64 algorithm for reproducibility + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + return state + } +} + +// MARK: - Fixture Generator + +struct FixtureGenerator { + + // MARK: - Configuration + + struct Configuration { + var seed: UInt64 = 12345 + var gameCount: Int = 50 + var stadiumCount: Int = 30 + var teamCount: Int = 30 + var dateRange: ClosedRange = { + let start = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 1))! + let end = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 30))! + return start...end + }() + var sports: Set = [.mlb, .nba, .nhl] + var geographicSpread: GeographicSpread = .nationwide + + enum GeographicSpread { + case nationwide // Full US coverage + case regional // Concentrated in one region + case corridor // Along a route (e.g., East Coast) + case cluster // Single metro area + } + + static var `default`: Configuration { Configuration() } + static var minimal: Configuration { Configuration(gameCount: 5, stadiumCount: 5, teamCount: 5) } + static var small: Configuration { Configuration(gameCount: 50, stadiumCount: 15, teamCount: 15) } + static var medium: Configuration { Configuration(gameCount: 500, stadiumCount: 30, teamCount: 30) } + static var large: Configuration { Configuration(gameCount: 2000, stadiumCount: 30, teamCount: 60) } + static var stress: Configuration { Configuration(gameCount: 10000, stadiumCount: 30, teamCount: 60) } + } + + // MARK: - Generated Data Container + + struct GeneratedData { + let stadiums: [Stadium] + let teams: [Team] + let games: [Game] + let stadiumsById: [UUID: Stadium] + let teamsById: [UUID: Team] + + func richGame(from game: Game) -> RichGame? { + guard let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] else { + return nil + } + return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } + + func richGames() -> [RichGame] { + games.compactMap { richGame(from: $0) } + } + } + + // MARK: - City Data for Realistic Generation + + private static let cityData: [(name: String, state: String, lat: Double, lon: Double, region: Region)] = [ + // East Coast + ("New York", "NY", 40.7128, -73.9352, .east), + ("Boston", "MA", 42.3601, -71.0589, .east), + ("Philadelphia", "PA", 39.9526, -75.1652, .east), + ("Washington", "DC", 38.9072, -77.0369, .east), + ("Baltimore", "MD", 39.2904, -76.6122, .east), + ("Miami", "FL", 25.7617, -80.1918, .east), + ("Tampa", "FL", 27.9506, -82.4572, .east), + ("Atlanta", "GA", 33.7490, -84.3880, .east), + ("Charlotte", "NC", 35.2271, -80.8431, .east), + ("Pittsburgh", "PA", 40.4406, -79.9959, .east), + + // Central + ("Chicago", "IL", 41.8781, -87.6298, .central), + ("Detroit", "MI", 42.3314, -83.0458, .central), + ("Cleveland", "OH", 41.4993, -81.6944, .central), + ("Cincinnati", "OH", 39.1031, -84.5120, .central), + ("Milwaukee", "WI", 43.0389, -87.9065, .central), + ("Minneapolis", "MN", 44.9778, -93.2650, .central), + ("St. Louis", "MO", 38.6270, -90.1994, .central), + ("Kansas City", "MO", 39.0997, -94.5786, .central), + ("Dallas", "TX", 32.7767, -96.7970, .central), + ("Houston", "TX", 29.7604, -95.3698, .central), + + // West Coast + ("Los Angeles", "CA", 34.0522, -118.2437, .west), + ("San Francisco", "CA", 37.7749, -122.4194, .west), + ("San Diego", "CA", 32.7157, -117.1611, .west), + ("Seattle", "WA", 47.6062, -122.3321, .west), + ("Portland", "OR", 45.5152, -122.6784, .west), + ("Phoenix", "AZ", 33.4484, -112.0740, .west), + ("Denver", "CO", 39.7392, -104.9903, .west), + ("Salt Lake City", "UT", 40.7608, -111.8910, .west), + ("Las Vegas", "NV", 36.1699, -115.1398, .west), + ("Oakland", "CA", 37.8044, -122.2712, .west), + ] + + private static let teamNames = [ + "Eagles", "Tigers", "Bears", "Lions", "Panthers", + "Hawks", "Wolves", "Sharks", "Dragons", "Knights", + "Royals", "Giants", "Cardinals", "Mariners", "Brewers", + "Rangers", "Padres", "Dodgers", "Mets", "Yankees", + "Cubs", "Sox", "Twins", "Rays", "Marlins", + "Nationals", "Braves", "Reds", "Pirates", "Orioles" + ] + + // MARK: - Generation + + static func generate(with config: Configuration = .default) -> GeneratedData { + var rng = SeededRandomNumberGenerator(seed: config.seed) + + // Generate stadiums + let stadiums = generateStadiums(config: config, rng: &rng) + let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) }) + + // Generate teams (2 per stadium typically) + let teams = generateTeams(stadiums: stadiums, config: config, rng: &rng) + let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) }) + + // Generate games + let games = generateGames(teams: teams, stadiums: stadiums, config: config, rng: &rng) + + return GeneratedData( + stadiums: stadiums, + teams: teams, + games: games, + stadiumsById: stadiumsById, + teamsById: teamsById + ) + } + + private static func generateStadiums( + config: Configuration, + rng: inout SeededRandomNumberGenerator + ) -> [Stadium] { + let cities = selectCities(for: config.geographicSpread, count: config.stadiumCount, rng: &rng) + + return cities.enumerated().map { index, city in + let sport = config.sports.randomElement(using: &rng) ?? .mlb + return Stadium( + id: UUID(), + name: "\(city.name) \(sport.rawValue) Stadium", + city: city.name, + state: city.state, + latitude: city.lat + Double.random(in: -0.05...0.05, using: &rng), + longitude: city.lon + Double.random(in: -0.05...0.05, using: &rng), + capacity: Int.random(in: 20000...60000, using: &rng), + sport: sport, + yearOpened: Int.random(in: 1990...2024, using: &rng) + ) + } + } + + private static func generateTeams( + stadiums: [Stadium], + config: Configuration, + rng: inout SeededRandomNumberGenerator + ) -> [Team] { + var teams: [Team] = [] + var usedNames = Set() + + for stadium in stadiums { + // Each stadium gets 1-2 teams + let teamCountForStadium = Int.random(in: 1...2, using: &rng) + + for _ in 0.. [Game] { + var games: [Game] = [] + let calendar = Calendar.current + + let dateRangeDays = calendar.dateComponents([.day], from: config.dateRange.lowerBound, to: config.dateRange.upperBound).day ?? 180 + + for _ in 0..= 2 else { break } + + // Pick random home team + let homeTeam = teams.randomElement(using: &rng)! + + // Pick random away team (different from home) + var awayTeam: Team + repeat { + awayTeam = teams.randomElement(using: &rng)! + } while awayTeam.id == homeTeam.id + + // Find home team's stadium + let stadium = stadiums.first { $0.id == homeTeam.stadiumId } ?? stadiums[0] + + // Random date within range + let daysOffset = Int.random(in: 0.. [(name: String, state: String, lat: Double, lon: Double, region: Region)] { + let cities: [(name: String, state: String, lat: Double, lon: Double, region: Region)] + + switch spread { + case .nationwide: + cities = cityData.shuffled(using: &rng) + case .regional: + let region = Region.allCases.randomElement(using: &rng) ?? .east + cities = cityData.filter { $0.region == region }.shuffled(using: &rng) + case .corridor: + // East Coast corridor + cities = cityData.filter { $0.region == .east }.shuffled(using: &rng) + case .cluster: + // Just pick one city and create variations + let baseCity = cityData.randomElement(using: &rng)! + cities = (0.. Trip { + let prefs = preferences ?? TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: startDate, + endDate: Calendar.current.date(byAdding: .day, value: stops * 2, to: startDate)! + ) + + var tripStops: [TripStop] = [] + var currentDate = startDate + + for i in 0.. [Game] { + cities.map { city in + let stadiumId = UUID() + return Game( + id: UUID(), + homeTeamId: UUID(), + awayTeamId: UUID(), + stadiumId: stadiumId, + dateTime: date, + sport: .mlb, + season: "2026" + ) + } + } + + /// Generate a stadium at a specific location + static func makeStadium( + id: UUID = UUID(), + name: String = "Test Stadium", + city: String = "Test City", + state: String = "TS", + latitude: Double = 40.0, + longitude: Double = -100.0, + capacity: Int = 40000, + sport: Sport = .mlb + ) -> Stadium { + Stadium( + id: id, + name: name, + city: city, + state: state, + latitude: latitude, + longitude: longitude, + capacity: capacity, + sport: sport + ) + } + + /// Generate a team + static func makeTeam( + id: UUID = UUID(), + name: String = "Test Team", + abbreviation: String = "TST", + sport: Sport = .mlb, + city: String = "Test City", + stadiumId: UUID + ) -> Team { + Team( + id: id, + name: name, + abbreviation: abbreviation, + sport: sport, + city: city, + stadiumId: stadiumId + ) + } + + /// Generate a game + static func makeGame( + id: UUID = UUID(), + homeTeamId: UUID, + awayTeamId: UUID, + stadiumId: UUID, + dateTime: Date = Date(), + sport: Sport = .mlb, + season: String = "2026", + isPlayoff: Bool = false + ) -> Game { + Game( + id: id, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + stadiumId: stadiumId, + dateTime: dateTime, + sport: sport, + season: season, + isPlayoff: isPlayoff + ) + } + + /// Generate a travel segment + static func makeTravelSegment( + from: LocationInput, + to: LocationInput, + distanceMiles: Double = 100, + durationHours: Double = 2 + ) -> TravelSegment { + TravelSegment( + fromLocation: from, + toLocation: to, + travelMode: .drive, + distanceMeters: distanceMiles * TestConstants.metersPerMile, + durationSeconds: durationHours * 3600 + ) + } + + /// Generate a trip stop + static func makeTripStop( + stopNumber: Int = 1, + city: String = "Test City", + state: String = "TS", + coordinate: CLLocationCoordinate2D? = nil, + arrivalDate: Date = Date(), + departureDate: Date? = nil, + games: [UUID] = [], + stadium: UUID? = nil, + isRestDay: Bool = false + ) -> TripStop { + TripStop( + stopNumber: stopNumber, + city: city, + state: state, + coordinate: coordinate, + arrivalDate: arrivalDate, + departureDate: departureDate ?? Calendar.current.date(byAdding: .day, value: 1, to: arrivalDate)!, + games: games, + stadium: stadium, + isRestDay: isRestDay + ) + } + + // MARK: - Known Locations for Testing + + struct KnownLocations { + static let nyc = CLLocationCoordinate2D(latitude: 40.7128, longitude: -73.9352) + static let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) + static let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298) + static let boston = CLLocationCoordinate2D(latitude: 42.3601, longitude: -71.0589) + static let miami = CLLocationCoordinate2D(latitude: 25.7617, longitude: -80.1918) + static let seattle = CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321) + static let denver = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) + + // Antipodal point (for testing haversine at extreme distances) + static let antipodal = CLLocationCoordinate2D(latitude: -40.7128, longitude: 106.0648) + } +} diff --git a/SportsTimeTests/GameDAGRouterTests.swift b/SportsTimeTests/GameDAGRouterTests.swift deleted file mode 100644 index fb400b6..0000000 --- a/SportsTimeTests/GameDAGRouterTests.swift +++ /dev/null @@ -1,806 +0,0 @@ -// -// 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") - } - - // MARK: - Performance Tests - - /// Generates a large dataset of games and stadiums for performance testing. - /// Games are distributed across stadiums and days to simulate realistic data. - private func generateLargeDataset( - gameCount: Int, - stadiumCount: Int, - daysSpan: Int - ) -> (games: [Game], stadiums: [UUID: Stadium]) { - // Create stadiums distributed across the US (roughly) - var stadiums: [UUID: Stadium] = [:] - let baseDate = date("2026-06-01 19:00") - - for i in 0.. 0, "Should return routes") - #expect(elapsed < .seconds(2), "Should complete within 2 seconds, actual: \(elapsed)") - } - - @Test("Performance: 5000 games completes in under 10 seconds") - func performance_5000Games_CompletesInTime() { - let (games, stadiums) = generateLargeDataset(gameCount: 5000, stadiumCount: 100, daysSpan: 60) - - let start = ContinuousClock.now - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - let elapsed = start.duration(to: .now) - - #expect(routes.count > 0, "Should return routes") - #expect(elapsed < .seconds(10), "Should complete within 10 seconds, actual: \(elapsed)") - } - - @Test("Performance: 10000 games completes in under 30 seconds") - func performance_10000Games_CompletesInTime() { - let (games, stadiums) = generateLargeDataset(gameCount: 10000, stadiumCount: 150, daysSpan: 90) - - let start = ContinuousClock.now - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - let elapsed = start.duration(to: .now) - - #expect(routes.count > 0, "Should return routes") - #expect(elapsed < .seconds(30), "Should complete within 30 seconds, actual: \(elapsed)") - } - - @Test("Performance: 10000 games does not cause memory issues") - func performance_10000Games_NoMemorySpike() { - let (games, stadiums) = generateLargeDataset(gameCount: 10000, stadiumCount: 150, daysSpan: 90) - - // Run the algorithm - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Verify routes returned (not OOM) - #expect(routes.count > 0, "Should return routes without memory crash") - #expect(routes.count <= 100, "Should return reasonable number of routes") - } - - // MARK: - Diversity Coverage Tests - - /// Generates a diverse dataset with many routing options for diversity testing - private func generateDiverseDataset() -> (games: [Game], stadiums: [UUID: Stadium]) { - // Create 20 stadiums in different regions - var stadiums: [UUID: Stadium] = [:] - let baseDate = date("2026-06-01 19:00") - - for i in 0..<20 { - let lat = 32.0 + Double(i % 10) * 2.0 // 32-50 latitude - let lon = -120.0 + Double(i / 10) * 30.0 // Spread across US - let stadium = makeStadium(city: "City\(i)", lat: lat, lon: lon) - stadiums[stadium.id] = stadium - } - - // Create 50 games over 14 days with varied scheduling - var games: [Game] = [] - let stadiumIds = Array(stadiums.keys) - - for i in 0..<50 { - let dayOffset = Double(i / 4) // ~4 games per day - let hoursOffset = Double(i % 4) * 3.0 // Spread within day - let gameTime = baseDate.addingTimeInterval(dayOffset * 24 * 3600 + hoursOffset * 3600) - - let stadiumId = stadiumIds[i % stadiumIds.count] - let game = makeGame(stadiumId: stadiumId, startTime: gameTime) - games.append(game) - } - - return (games, stadiums) - } - - @Test("Diversity: routes include varied game counts") - func diversity_VariedGameCounts() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - let gameCounts = Set(routes.map { $0.count }) - - #expect(gameCounts.count >= 3, "Should have at least 3 different route lengths, got \(gameCounts.count)") - #expect(gameCounts.contains(where: { $0 <= 3 }), "Should include short routes (≤3 games)") - #expect(gameCounts.contains(where: { $0 >= 5 }), "Should include long routes (≥5 games)") - } - - @Test("Diversity: routes span different numbers of cities") - func diversity_VariedCityCounts() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Calculate city counts for each route - let cityCounts = routes.map { route in - Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count - } - let uniqueCityCounts = Set(cityCounts) - - #expect(uniqueCityCounts.count >= 3, "Should have at least 3 different city count variations, got \(uniqueCityCounts.count)") - #expect(cityCounts.contains(where: { $0 <= 3 }), "Should include routes with ≤3 cities") - #expect(cityCounts.contains(where: { $0 >= 4 }), "Should include routes with ≥4 cities") - } - - @Test("Diversity: routes include varied mileage ranges") - func diversity_VariedMileage() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Calculate total mileage for each route - let mileages = routes.map { route -> Double in - var totalMiles: Double = 0 - for i in 0..<(route.count - 1) { - let from = stadiums[route[i].stadiumId]! - let to = stadiums[route[i + 1].stadiumId]! - let distance = TravelEstimator.haversineDistanceMiles( - from: CLLocationCoordinate2D(latitude: from.coordinate.latitude, longitude: from.coordinate.longitude), - to: CLLocationCoordinate2D(latitude: to.coordinate.latitude, longitude: to.coordinate.longitude) - ) * 1.3 - totalMiles += distance - } - return totalMiles - } - - let shortRoutes = mileages.filter { $0 < 500 } - let mediumRoutes = mileages.filter { $0 >= 500 && $0 < 1000 } - let longRoutes = mileages.filter { $0 >= 1000 } - - #expect(shortRoutes.count > 0, "Should include short routes (<500 miles)") - #expect(mediumRoutes.count > 0 || longRoutes.count > 0, "Should include medium (500-1000mi) or long (≥1000mi) routes") - } - - @Test("Diversity: routes include varied durations") - func diversity_VariedDurations() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Calculate duration for each route - let durations = routes.compactMap { route -> Int? in - guard let first = route.first, let last = route.last else { return nil } - let calendar = Calendar.current - let days = calendar.dateComponents([.day], from: first.startTime, to: last.startTime).day ?? 0 - return max(1, days + 1) - } - let uniqueDurations = Set(durations) - - #expect(uniqueDurations.count >= 3, "Should have at least 3 different duration variations, got \(uniqueDurations.count)") - #expect(durations.contains(where: { $0 <= 3 }), "Should include short duration routes (≤3 days)") - #expect(durations.contains(where: { $0 >= 5 }), "Should include long duration routes (≥5 days)") - } - - @Test("Diversity: at least 3 game count buckets represented") - func diversity_BucketCoverage() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Map game counts to buckets (1, 2, 3, 4, 5+) - let buckets = routes.map { route -> Int in - let count = route.count - return count >= 5 ? 5 : count // Bucket 5+ as 5 - } - let uniqueBuckets = Set(buckets) - - #expect(uniqueBuckets.count >= 3, "Should have at least 3 different game count buckets represented, got \(uniqueBuckets.count)") - } - - @Test("Diversity: no duplicate routes") - func diversity_NoDuplicates() { - let (games, stadiums) = generateDiverseDataset() - let routes = GameDAGRouter.findRoutes( - games: games, - stadiums: stadiums, - constraints: .default - ) - - // Create unique keys for each route (sorted game IDs) - let routeKeys = routes.map { route in - route.map { $0.id.uuidString }.sorted().joined(separator: "-") - } - let uniqueKeys = Set(routeKeys) - - #expect(routeKeys.count == uniqueKeys.count, "All routes should be unique, found \(routeKeys.count - uniqueKeys.count) duplicates") - } -} diff --git a/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift b/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift new file mode 100644 index 0000000..c28f147 --- /dev/null +++ b/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift @@ -0,0 +1,305 @@ +// +// BruteForceRouteVerifier.swift +// SportsTimeTests +// +// Exhaustively enumerates all route permutations to verify optimality. +// Used for inputs with ≤8 stops where brute force is feasible. +// + +import Foundation +import CoreLocation +@testable import SportsTime + +// MARK: - Route Verifier + +struct BruteForceRouteVerifier { + + // MARK: - Route Comparison Result + + struct VerificationResult { + let isOptimal: Bool + let proposedRouteDistance: Double + let optimalRouteDistance: Double + let optimalRoute: [UUID]? + let improvement: Double? // Percentage improvement if not optimal + let permutationsChecked: Int + + var improvementPercentage: Double? { + guard let improvement = improvement else { return nil } + return improvement * 100 + } + } + + // MARK: - Verification + + /// Verify that a proposed route is optimal (or near-optimal) by checking all permutations + /// - Parameters: + /// - proposedRoute: The route to verify (ordered list of stop IDs) + /// - stops: Dictionary mapping stop IDs to their coordinates + /// - tolerance: Percentage tolerance for "near-optimal" (default 0 = must be exactly optimal) + /// - Returns: Verification result + static func verify( + proposedRoute: [UUID], + stops: [UUID: CLLocationCoordinate2D], + tolerance: Double = 0 + ) -> VerificationResult { + guard proposedRoute.count <= TestConstants.bruteForceMaxStops else { + fatalError("BruteForceRouteVerifier should only be used for ≤\(TestConstants.bruteForceMaxStops) stops") + } + + guard proposedRoute.count >= 2 else { + // Single stop or empty - trivially optimal + return VerificationResult( + isOptimal: true, + proposedRouteDistance: 0, + optimalRouteDistance: 0, + optimalRoute: proposedRoute, + improvement: nil, + permutationsChecked: 1 + ) + } + + let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops) + + // Find optimal route by checking all permutations + let allPermutations = permutations(of: proposedRoute) + var optimalDistance = Double.infinity + var optimalRoute: [UUID] = [] + + for permutation in allPermutations { + let distance = calculateRouteDistance(permutation, stops: stops) + if distance < optimalDistance { + optimalDistance = distance + optimalRoute = permutation + } + } + + let isOptimal: Bool + var improvement: Double? = nil + + if tolerance == 0 { + // Exact optimality check with floating point tolerance + isOptimal = abs(proposedDistance - optimalDistance) < 0.001 + } else { + // Within tolerance + let maxAllowed = optimalDistance * (1 + tolerance) + isOptimal = proposedDistance <= maxAllowed + } + + if !isOptimal && optimalDistance > 0 { + improvement = (proposedDistance - optimalDistance) / optimalDistance + } + + return VerificationResult( + isOptimal: isOptimal, + proposedRouteDistance: proposedDistance, + optimalRouteDistance: optimalDistance, + optimalRoute: optimalRoute, + improvement: improvement, + permutationsChecked: allPermutations.count + ) + } + + /// Verify a route is optimal with a fixed start and end point + static func verifyWithFixedEndpoints( + proposedRoute: [UUID], + stops: [UUID: CLLocationCoordinate2D], + startId: UUID, + endId: UUID, + tolerance: Double = 0 + ) -> VerificationResult { + guard proposedRoute.first == startId && proposedRoute.last == endId else { + // Invalid route - doesn't match required endpoints + return VerificationResult( + isOptimal: false, + proposedRouteDistance: Double.infinity, + optimalRouteDistance: 0, + optimalRoute: nil, + improvement: nil, + permutationsChecked: 0 + ) + } + + // Get intermediate stops (excluding start and end) + let intermediateStops = proposedRoute.dropFirst().dropLast() + + guard intermediateStops.count <= TestConstants.bruteForceMaxStops - 2 else { + fatalError("BruteForceRouteVerifier: too many intermediate stops") + } + + let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops) + + // Generate all permutations of intermediate stops + let allPermutations = permutations(of: Array(intermediateStops)) + var optimalDistance = Double.infinity + var optimalRoute: [UUID] = [] + + for permutation in allPermutations { + var fullRoute = [startId] + fullRoute.append(contentsOf: permutation) + fullRoute.append(endId) + + let distance = calculateRouteDistance(fullRoute, stops: stops) + if distance < optimalDistance { + optimalDistance = distance + optimalRoute = fullRoute + } + } + + let isOptimal: Bool + var improvement: Double? = nil + + if tolerance == 0 { + isOptimal = abs(proposedDistance - optimalDistance) < 0.001 + } else { + let maxAllowed = optimalDistance * (1 + tolerance) + isOptimal = proposedDistance <= maxAllowed + } + + if !isOptimal && optimalDistance > 0 { + improvement = (proposedDistance - optimalDistance) / optimalDistance + } + + return VerificationResult( + isOptimal: isOptimal, + proposedRouteDistance: proposedDistance, + optimalRouteDistance: optimalDistance, + optimalRoute: optimalRoute, + improvement: improvement, + permutationsChecked: allPermutations.count + ) + } + + /// Check if there's an obviously better route (significantly shorter) + static func hasObviouslyBetterRoute( + proposedRoute: [UUID], + stops: [UUID: CLLocationCoordinate2D], + threshold: Double = 0.1 // 10% improvement threshold + ) -> (hasBetter: Bool, improvement: Double?) { + let result = verify(proposedRoute: proposedRoute, stops: stops, tolerance: threshold) + return (!result.isOptimal, result.improvement) + } + + // MARK: - Distance Calculation + + /// Calculate total route distance using haversine formula + static func calculateRouteDistance( + _ route: [UUID], + stops: [UUID: CLLocationCoordinate2D] + ) -> Double { + guard route.count >= 2 else { return 0 } + + var totalDistance: Double = 0 + + for i in 0..<(route.count - 1) { + guard let from = stops[route[i]], + let to = stops[route[i + 1]] else { + continue + } + totalDistance += haversineDistanceMiles(from: from, to: to) + } + + return totalDistance + } + + /// Haversine distance between two coordinates in miles + static func haversineDistanceMiles( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> Double { + let earthRadiusMiles = TestConstants.earthRadiusMiles + + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let deltaLat = (to.latitude - from.latitude) * .pi / 180 + let deltaLon = (to.longitude - from.longitude) * .pi / 180 + + let a = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * + sin(deltaLon / 2) * sin(deltaLon / 2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return earthRadiusMiles * c + } + + // MARK: - Permutation Generation + + /// Generate all permutations of an array (Heap's algorithm) + static func permutations(of array: [T]) -> [[T]] { + var result: [[T]] = [] + var arr = array + + func generate(_ n: Int) { + if n == 1 { + result.append(arr) + return + } + + for i in 0.. Int { + guard n > 1 else { return 1 } + return (1...n).reduce(1, *) + } +} + +// MARK: - Convenience Extensions + +extension BruteForceRouteVerifier { + /// Verify a trip's route is optimal + static func verifyTrip(_ trip: Trip) -> VerificationResult { + var stops: [UUID: CLLocationCoordinate2D] = [:] + + for stop in trip.stops { + if let coord = stop.coordinate { + stops[stop.id] = coord + } + } + + let routeIds = trip.stops.map { $0.id } + return verify(proposedRoute: routeIds, stops: stops) + } + + /// Verify a list of stadiums forms an optimal route + static func verifyStadiumRoute(_ stadiums: [Stadium]) -> VerificationResult { + let stops = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0.coordinate) }) + let routeIds = stadiums.map { $0.id } + return verify(proposedRoute: routeIds, stops: stops) + } +} + +// MARK: - Test Assertions + +extension BruteForceRouteVerifier.VerificationResult { + /// Returns a detailed failure message if not optimal + var failureMessage: String? { + guard !isOptimal else { return nil } + + var message = "Route is not optimal. " + message += "Proposed: \(String(format: "%.1f", proposedRouteDistance)) miles, " + message += "Optimal: \(String(format: "%.1f", optimalRouteDistance)) miles" + + if let improvement = improvementPercentage { + message += " (\(String(format: "%.1f", improvement))% longer)" + } + + message += ". Checked \(permutationsChecked) permutations." + + return message + } +} diff --git a/SportsTimeTests/Helpers/TestConstants.swift b/SportsTimeTests/Helpers/TestConstants.swift new file mode 100644 index 0000000..21eb03d --- /dev/null +++ b/SportsTimeTests/Helpers/TestConstants.swift @@ -0,0 +1,87 @@ +// +// TestConstants.swift +// SportsTimeTests +// +// Constants used across test suites for consistent test configuration. +// + +import Foundation + +enum TestConstants { + // MARK: - Distance & Radius + + /// Standard radius for "nearby" game filtering (miles) + static let nearbyRadiusMiles: Double = 50.0 + + /// Meters per mile conversion + static let metersPerMile: Double = 1609.344 + + /// Nearby radius in meters + static var nearbyRadiusMeters: Double { nearbyRadiusMiles * metersPerMile } + + // MARK: - Timeouts + + /// Maximum time for performance/scale tests (5 minutes) + static let performanceTimeout: TimeInterval = 300.0 + + /// Maximum time before a test is considered hung (30 seconds) + static let hangTimeout: TimeInterval = 30.0 + + /// Standard async test timeout + static let standardTimeout: TimeInterval = 10.0 + + // MARK: - Performance Baselines + // These will be recorded after initial runs and updated + + /// Baseline time for 500 games (to be determined) + static var baseline500Games: TimeInterval = 0 + + /// Baseline time for 2000 games (to be determined) + static var baseline2000Games: TimeInterval = 0 + + /// Baseline time for 10000 games (to be determined) + static var baseline10000Games: TimeInterval = 0 + + // MARK: - Driving Constraints + + /// Default max driving hours per day (single driver) + static let defaultMaxDrivingHoursPerDay: Double = 8.0 + + /// Average driving speed (mph) for estimates + static let averageDrivingSpeedMPH: Double = 60.0 + + /// Max days lookahead for game transitions + static let maxDayLookahead: Int = 5 + + // MARK: - Brute Force Verification + + /// Maximum number of stops for brute force verification + static let bruteForceMaxStops: Int = 8 + + // MARK: - Test Data Sizes + + enum DataSize: Int { + case tiny = 5 + case small = 50 + case medium = 500 + case large = 2000 + case stress = 10000 + case extreme = 50000 + } + + // MARK: - Geographic Constants + + /// Earth radius in miles (for haversine) + static let earthRadiusMiles: Double = 3958.8 + + /// Earth circumference in miles + static let earthCircumferenceMiles: Double = 24901.0 + + // MARK: - Known Distances (for validation) + + /// NYC to LA approximate distance in miles + static let nycToLAMiles: Double = 2451.0 + + /// Distance tolerance percentage for validation + static let distanceTolerancePercent: Double = 0.01 // 1% +} diff --git a/SportsTimeTests/Mocks/MockAppDataProvider.swift b/SportsTimeTests/Mocks/MockAppDataProvider.swift new file mode 100644 index 0000000..3278d6f --- /dev/null +++ b/SportsTimeTests/Mocks/MockAppDataProvider.swift @@ -0,0 +1,264 @@ +// +// MockAppDataProvider.swift +// SportsTimeTests +// +// Mock implementation of AppDataProvider for testing without SwiftData dependencies. +// + +import Foundation +import Combine +@testable import SportsTime + +// MARK: - Mock App Data Provider + +@MainActor +final class MockAppDataProvider: ObservableObject { + + // MARK: - Published State + + @Published private(set) var teams: [Team] = [] + @Published private(set) var stadiums: [Stadium] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: Error? + @Published private(set) var errorMessage: String? + + // MARK: - Internal Storage + + private var teamsById: [UUID: Team] = [:] + private var stadiumsById: [UUID: Stadium] = [:] + private var games: [Game] = [] + private var gamesById: [UUID: Game] = [:] + + // MARK: - Configuration + + struct Configuration { + var simulatedLatency: TimeInterval = 0 + var shouldFailOnLoad: Bool = false + var shouldFailOnFetch: Bool = false + var isEmpty: Bool = false + + static var `default`: Configuration { Configuration() } + static var empty: Configuration { Configuration(isEmpty: true) } + static var failing: Configuration { Configuration(shouldFailOnLoad: true) } + static var slow: Configuration { Configuration(simulatedLatency: 1.0) } + } + + private var config: Configuration + + // MARK: - Call Tracking + + private(set) var loadInitialDataCallCount = 0 + private(set) var fetchGamesCallCount = 0 + private(set) var fetchRichGamesCallCount = 0 + + // MARK: - Initialization + + init(config: Configuration = .default) { + self.config = config + } + + // MARK: - Configuration Methods + + func configure(_ newConfig: Configuration) { + self.config = newConfig + } + + func setTeams(_ newTeams: [Team]) { + self.teams = newTeams + self.teamsById = Dictionary(uniqueKeysWithValues: newTeams.map { ($0.id, $0) }) + } + + func setStadiums(_ newStadiums: [Stadium]) { + self.stadiums = newStadiums + self.stadiumsById = Dictionary(uniqueKeysWithValues: newStadiums.map { ($0.id, $0) }) + } + + func setGames(_ newGames: [Game]) { + self.games = newGames + self.gamesById = Dictionary(uniqueKeysWithValues: newGames.map { ($0.id, $0) }) + } + + func reset() { + teams = [] + stadiums = [] + games = [] + teamsById = [:] + stadiumsById = [:] + gamesById = [:] + isLoading = false + error = nil + errorMessage = nil + loadInitialDataCallCount = 0 + fetchGamesCallCount = 0 + fetchRichGamesCallCount = 0 + config = .default + } + + // MARK: - Simulated Network + + private func simulateLatency() async { + if config.simulatedLatency > 0 { + try? await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) + } + } + + // MARK: - Data Loading + + func loadInitialData() async { + loadInitialDataCallCount += 1 + + if config.isEmpty { + teams = [] + stadiums = [] + return + } + + isLoading = true + error = nil + errorMessage = nil + + await simulateLatency() + + if config.shouldFailOnLoad { + error = DataProviderError.contextNotConfigured + errorMessage = "Mock load failure" + isLoading = false + return + } + + isLoading = false + } + + func clearError() { + error = nil + errorMessage = nil + } + + func retry() async { + await loadInitialData() + } + + // MARK: - Data Access + + func team(for id: UUID) -> Team? { + teamsById[id] + } + + func stadium(for id: UUID) -> Stadium? { + stadiumsById[id] + } + + func teams(for sport: Sport) -> [Team] { + teams.filter { $0.sport == sport } + } + + // MARK: - Game Fetching + + func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { + fetchGamesCallCount += 1 + await simulateLatency() + + if config.shouldFailOnFetch { + throw DataProviderError.contextNotConfigured + } + + return games.filter { game in + sports.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate + }.sorted { $0.dateTime < $1.dateTime } + } + + func fetchGame(by id: UUID) async throws -> Game? { + await simulateLatency() + + if config.shouldFailOnFetch { + throw DataProviderError.contextNotConfigured + } + + return gamesById[id] + } + + func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + fetchRichGamesCallCount += 1 + let filteredGames = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) + + return filteredGames.compactMap { game in + richGame(from: game) + } + } + + func richGame(from game: Game) -> RichGame? { + guard let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] else { + return nil + } + return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } +} + +// MARK: - Convenience Extensions + +extension MockAppDataProvider { + /// Load fixture data from FixtureGenerator + func loadFixtures(_ data: FixtureGenerator.GeneratedData) { + setTeams(data.teams) + setStadiums(data.stadiums) + setGames(data.games) + } + + /// Create a mock provider with fixture data pre-loaded + static func withFixtures(_ config: FixtureGenerator.Configuration = .default) -> MockAppDataProvider { + let mock = MockAppDataProvider() + let data = FixtureGenerator.generate(with: config) + mock.loadFixtures(data) + return mock + } + + /// Create a mock provider configured as empty + static var empty: MockAppDataProvider { + MockAppDataProvider(config: .empty) + } + + /// Create a mock provider configured to fail + static var failing: MockAppDataProvider { + MockAppDataProvider(config: .failing) + } +} + +// MARK: - Test Helpers + +extension MockAppDataProvider { + /// Add a single game + func addGame(_ game: Game) { + games.append(game) + gamesById[game.id] = game + } + + /// Add a single team + func addTeam(_ team: Team) { + teams.append(team) + teamsById[team.id] = team + } + + /// Add a single stadium + func addStadium(_ stadium: Stadium) { + stadiums.append(stadium) + stadiumsById[stadium.id] = stadium + } + + /// Get all games (for test verification) + func allGames() -> [Game] { + games + } + + /// Get games count + var gamesCount: Int { games.count } + + /// Get teams count + var teamsCount: Int { teams.count } + + /// Get stadiums count + var stadiumsCount: Int { stadiums.count } +} diff --git a/SportsTimeTests/Mocks/MockCloudKitService.swift b/SportsTimeTests/Mocks/MockCloudKitService.swift new file mode 100644 index 0000000..832d721 --- /dev/null +++ b/SportsTimeTests/Mocks/MockCloudKitService.swift @@ -0,0 +1,294 @@ +// +// MockCloudKitService.swift +// SportsTimeTests +// +// Mock implementation of CloudKitService for testing without network dependencies. +// + +import Foundation +@testable import SportsTime + +// MARK: - Mock CloudKit Service + +actor MockCloudKitService { + + // MARK: - Configuration + + struct Configuration { + var isAvailable: Bool = true + var simulatedLatency: TimeInterval = 0 + var shouldFailWithError: CloudKitError? = nil + var errorAfterNCalls: Int? = nil + + static var `default`: Configuration { Configuration() } + static var offline: Configuration { Configuration(isAvailable: false) } + static var slow: Configuration { Configuration(simulatedLatency: 2.0) } + } + + // MARK: - Stored Data + + private var stadiums: [Stadium] = [] + private var teams: [Team] = [] + private var games: [Game] = [] + private var leagueStructure: [LeagueStructureModel] = [] + private var teamAliases: [TeamAlias] = [] + private var stadiumAliases: [StadiumAlias] = [] + + // MARK: - Call Tracking + + private(set) var fetchStadiumsCallCount = 0 + private(set) var fetchTeamsCallCount = 0 + private(set) var fetchGamesCallCount = 0 + private(set) var isAvailableCallCount = 0 + + // MARK: - Configuration + + private var config: Configuration + + // MARK: - Initialization + + init(config: Configuration = .default) { + self.config = config + } + + // MARK: - Configuration Methods + + func configure(_ newConfig: Configuration) { + self.config = newConfig + } + + func setStadiums(_ stadiums: [Stadium]) { + self.stadiums = stadiums + } + + func setTeams(_ teams: [Team]) { + self.teams = teams + } + + func setGames(_ games: [Game]) { + self.games = games + } + + func setLeagueStructure(_ structure: [LeagueStructureModel]) { + self.leagueStructure = structure + } + + func reset() { + stadiums = [] + teams = [] + games = [] + leagueStructure = [] + teamAliases = [] + stadiumAliases = [] + fetchStadiumsCallCount = 0 + fetchTeamsCallCount = 0 + fetchGamesCallCount = 0 + isAvailableCallCount = 0 + config = .default + } + + // MARK: - Simulated Network + + private func simulateNetwork() async throws { + // Simulate latency + if config.simulatedLatency > 0 { + try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) + } + + // Check for configured error + if let error = config.shouldFailWithError { + throw error + } + } + + private func checkErrorAfterNCalls(_ callCount: Int) throws { + if let errorAfterN = config.errorAfterNCalls, callCount >= errorAfterN { + throw config.shouldFailWithError ?? CloudKitError.networkUnavailable + } + } + + // MARK: - Availability + + func isAvailable() async -> Bool { + isAvailableCallCount += 1 + return config.isAvailable + } + + func checkAvailabilityWithError() async throws { + if !config.isAvailable { + throw CloudKitError.networkUnavailable + } + if let error = config.shouldFailWithError { + throw error + } + } + + // MARK: - Fetch Operations + + func fetchStadiums() async throws -> [Stadium] { + fetchStadiumsCallCount += 1 + try checkErrorAfterNCalls(fetchStadiumsCallCount) + try await simulateNetwork() + return stadiums + } + + func fetchTeams(for sport: Sport) async throws -> [Team] { + fetchTeamsCallCount += 1 + try checkErrorAfterNCalls(fetchTeamsCallCount) + try await simulateNetwork() + return teams.filter { $0.sport == sport } + } + + func fetchGames( + sports: Set, + startDate: Date, + endDate: Date + ) async throws -> [Game] { + fetchGamesCallCount += 1 + try checkErrorAfterNCalls(fetchGamesCallCount) + try await simulateNetwork() + + return games.filter { game in + sports.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate + }.sorted { $0.dateTime < $1.dateTime } + } + + func fetchGame(by id: UUID) async throws -> Game? { + try await simulateNetwork() + return games.first { $0.id == id } + } + + // MARK: - Sync Fetch Methods + + func fetchStadiumsForSync() async throws -> [CloudKitService.SyncStadium] { + try await simulateNetwork() + return stadiums.map { stadium in + CloudKitService.SyncStadium( + stadium: stadium, + canonicalId: stadium.id.uuidString + ) + } + } + + func fetchTeamsForSync(for sport: Sport) async throws -> [CloudKitService.SyncTeam] { + try await simulateNetwork() + return teams.filter { $0.sport == sport }.map { team in + CloudKitService.SyncTeam( + team: team, + canonicalId: team.id.uuidString, + stadiumCanonicalId: team.stadiumId.uuidString + ) + } + } + + func fetchGamesForSync( + sports: Set, + startDate: Date, + endDate: Date + ) async throws -> [CloudKitService.SyncGame] { + try await simulateNetwork() + + return games.filter { game in + sports.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate + }.map { game in + CloudKitService.SyncGame( + game: game, + canonicalId: game.id.uuidString, + homeTeamCanonicalId: game.homeTeamId.uuidString, + awayTeamCanonicalId: game.awayTeamId.uuidString, + stadiumCanonicalId: game.stadiumId.uuidString + ) + } + } + + // MARK: - League Structure & Aliases + + func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] { + try await simulateNetwork() + if let sport = sport { + return leagueStructure.filter { $0.sport == sport.rawValue } + } + return leagueStructure + } + + func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] { + try await simulateNetwork() + if let teamId = teamCanonicalId { + return teamAliases.filter { $0.teamCanonicalId == teamId } + } + return teamAliases + } + + func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] { + try await simulateNetwork() + if let stadiumId = stadiumCanonicalId { + return stadiumAliases.filter { $0.stadiumCanonicalId == stadiumId } + } + return stadiumAliases + } + + // MARK: - Delta Sync + + func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] { + try await simulateNetwork() + guard let lastSync = lastSync else { + return leagueStructure + } + return leagueStructure.filter { $0.lastModified > lastSync } + } + + func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] { + try await simulateNetwork() + guard let lastSync = lastSync else { + return teamAliases + } + return teamAliases.filter { $0.lastModified > lastSync } + } + + func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] { + try await simulateNetwork() + guard let lastSync = lastSync else { + return stadiumAliases + } + return stadiumAliases.filter { $0.lastModified > lastSync } + } + + // MARK: - Subscriptions (No-ops for testing) + + func subscribeToScheduleUpdates() async throws {} + func subscribeToLeagueStructureUpdates() async throws {} + func subscribeToTeamAliasUpdates() async throws {} + func subscribeToStadiumAliasUpdates() async throws {} + func subscribeToAllUpdates() async throws {} +} + +// MARK: - Convenience Extensions + +extension MockCloudKitService { + /// Load fixture data from FixtureGenerator + func loadFixtures(_ data: FixtureGenerator.GeneratedData) { + Task { + await setStadiums(data.stadiums) + await setTeams(data.teams) + await setGames(data.games) + } + } + + /// Configure to simulate specific error scenarios + static func withError(_ error: CloudKitError) -> MockCloudKitService { + let mock = MockCloudKitService() + Task { + await mock.configure(Configuration(shouldFailWithError: error)) + } + return mock + } + + /// Configure to be offline + static var offline: MockCloudKitService { + MockCloudKitService(config: .offline) + } +} diff --git a/SportsTimeTests/Mocks/MockLocationService.swift b/SportsTimeTests/Mocks/MockLocationService.swift new file mode 100644 index 0000000..810c54f --- /dev/null +++ b/SportsTimeTests/Mocks/MockLocationService.swift @@ -0,0 +1,296 @@ +// +// MockLocationService.swift +// SportsTimeTests +// +// Mock implementation of LocationService for testing without MapKit dependencies. +// + +import Foundation +import CoreLocation +import MapKit +@testable import SportsTime + +// MARK: - Mock Location Service + +actor MockLocationService { + + // MARK: - Configuration + + struct Configuration { + var simulatedLatency: TimeInterval = 0 + var shouldFailGeocode: Bool = false + var shouldFailRoute: Bool = false + var defaultDrivingSpeedMPH: Double = 60.0 + var useHaversineForDistance: Bool = true + + static var `default`: Configuration { Configuration() } + static var slow: Configuration { Configuration(simulatedLatency: 1.0) } + static var failingGeocode: Configuration { Configuration(shouldFailGeocode: true) } + static var failingRoute: Configuration { Configuration(shouldFailRoute: true) } + } + + // MARK: - Pre-configured Responses + + private var geocodeResponses: [String: CLLocationCoordinate2D] = [:] + private var routeResponses: [String: RouteInfo] = [:] + + // MARK: - Call Tracking + + private(set) var geocodeCallCount = 0 + private(set) var reverseGeocodeCallCount = 0 + private(set) var calculateRouteCallCount = 0 + private(set) var searchLocationsCallCount = 0 + + // MARK: - Configuration + + private var config: Configuration + + // MARK: - Initialization + + init(config: Configuration = .default) { + self.config = config + } + + // MARK: - Configuration Methods + + func configure(_ newConfig: Configuration) { + self.config = newConfig + } + + func setGeocodeResponse(for address: String, coordinate: CLLocationCoordinate2D) { + geocodeResponses[address.lowercased()] = coordinate + } + + func setRouteResponse(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, route: RouteInfo) { + let key = routeKey(from: from, to: to) + routeResponses[key] = route + } + + func reset() { + geocodeResponses = [:] + routeResponses = [:] + geocodeCallCount = 0 + reverseGeocodeCallCount = 0 + calculateRouteCallCount = 0 + searchLocationsCallCount = 0 + config = .default + } + + // MARK: - Simulated Network + + private func simulateNetwork() async throws { + if config.simulatedLatency > 0 { + try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) + } + } + + // MARK: - Geocoding + + func geocode(_ address: String) async throws -> CLLocationCoordinate2D? { + geocodeCallCount += 1 + try await simulateNetwork() + + if config.shouldFailGeocode { + throw LocationError.geocodingFailed + } + + // Check pre-configured responses + if let coordinate = geocodeResponses[address.lowercased()] { + return coordinate + } + + // Return nil for unknown addresses (simulating "not found") + return nil + } + + func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> String? { + reverseGeocodeCallCount += 1 + try await simulateNetwork() + + if config.shouldFailGeocode { + throw LocationError.geocodingFailed + } + + // Return a simple formatted string based on coordinates + return "Location at \(String(format: "%.2f", coordinate.latitude)), \(String(format: "%.2f", coordinate.longitude))" + } + + func resolveLocation(_ input: LocationInput) async throws -> LocationInput { + if input.isResolved { return input } + + let searchText = input.address ?? input.name + guard let coordinate = try await geocode(searchText) else { + throw LocationError.geocodingFailed + } + + return LocationInput( + name: input.name, + coordinate: coordinate, + address: input.address + ) + } + + // MARK: - Location Search + + func searchLocations(_ query: String) async throws -> [LocationSearchResult] { + searchLocationsCallCount += 1 + try await simulateNetwork() + + if config.shouldFailGeocode { + return [] + } + + // Check if we have a pre-configured response for this query + if let coordinate = geocodeResponses[query.lowercased()] { + return [ + LocationSearchResult( + name: query, + address: "Mocked Address", + coordinate: coordinate + ) + ] + } + + return [] + } + + // MARK: - Distance Calculations + + func calculateDistance( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> CLLocationDistance { + if config.useHaversineForDistance { + return haversineDistance(from: from, to: to) + } + + // Simple Euclidean approximation (less accurate but faster) + let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude) + let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude) + return fromLocation.distance(from: toLocation) + } + + func calculateDrivingRoute( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) async throws -> RouteInfo { + calculateRouteCallCount += 1 + try await simulateNetwork() + + if config.shouldFailRoute { + throw LocationError.routeNotFound + } + + // Check pre-configured routes + let key = routeKey(from: from, to: to) + if let route = routeResponses[key] { + return route + } + + // Generate estimated route based on haversine distance + let distanceMeters = haversineDistance(from: from, to: to) + let distanceMiles = distanceMeters * 0.000621371 + + // Estimate driving time (add 20% for real-world conditions) + let drivingHours = (distanceMiles / config.defaultDrivingSpeedMPH) * 1.2 + let travelTimeSeconds = drivingHours * 3600 + + return RouteInfo( + distance: distanceMeters, + expectedTravelTime: travelTimeSeconds, + polyline: nil + ) + } + + func calculateDrivingMatrix( + origins: [CLLocationCoordinate2D], + destinations: [CLLocationCoordinate2D] + ) async throws -> [[RouteInfo?]] { + var matrix: [[RouteInfo?]] = [] + + for origin in origins { + var row: [RouteInfo?] = [] + for destination in destinations { + do { + let route = try await calculateDrivingRoute(from: origin, to: destination) + row.append(route) + } catch { + row.append(nil) + } + } + matrix.append(row) + } + + return matrix + } + + // MARK: - Haversine Distance + + /// Calculate haversine distance between two coordinates in meters + private func haversineDistance( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> CLLocationDistance { + let earthRadiusMeters: Double = 6371000.0 + + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let deltaLat = (to.latitude - from.latitude) * .pi / 180 + let deltaLon = (to.longitude - from.longitude) * .pi / 180 + + let a = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * + sin(deltaLon / 2) * sin(deltaLon / 2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return earthRadiusMeters * c + } + + // MARK: - Helpers + + private func routeKey(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> String { + "\(from.latitude),\(from.longitude)->\(to.latitude),\(to.longitude)" + } +} + +// MARK: - Convenience Extensions + +extension MockLocationService { + /// Pre-configure common city geocoding responses + func loadCommonCities() async { + await setGeocodeResponse(for: "New York, NY", coordinate: FixtureGenerator.KnownLocations.nyc) + await setGeocodeResponse(for: "Los Angeles, CA", coordinate: FixtureGenerator.KnownLocations.la) + await setGeocodeResponse(for: "Chicago, IL", coordinate: FixtureGenerator.KnownLocations.chicago) + await setGeocodeResponse(for: "Boston, MA", coordinate: FixtureGenerator.KnownLocations.boston) + await setGeocodeResponse(for: "Miami, FL", coordinate: FixtureGenerator.KnownLocations.miami) + await setGeocodeResponse(for: "Seattle, WA", coordinate: FixtureGenerator.KnownLocations.seattle) + await setGeocodeResponse(for: "Denver, CO", coordinate: FixtureGenerator.KnownLocations.denver) + } + + /// Create a mock service with common cities pre-loaded + static func withCommonCities() async -> MockLocationService { + let mock = MockLocationService() + await mock.loadCommonCities() + return mock + } +} + +// MARK: - Test Helpers + +extension MockLocationService { + /// Calculate expected travel time in hours for a given distance + func expectedTravelHours(distanceMiles: Double) -> Double { + (distanceMiles / config.defaultDrivingSpeedMPH) * 1.2 + } + + /// Check if a coordinate is within radius of another + func isWithinRadius( + _ coordinate: CLLocationCoordinate2D, + of center: CLLocationCoordinate2D, + radiusMiles: Double + ) -> Bool { + let distanceMeters = haversineDistance(from: center, to: coordinate) + let distanceMiles = distanceMeters * 0.000621371 + return distanceMiles <= radiusMiles + } +} diff --git a/SportsTimeTests/Planning/ConcurrencyTests.swift b/SportsTimeTests/Planning/ConcurrencyTests.swift new file mode 100644 index 0000000..58caf7c --- /dev/null +++ b/SportsTimeTests/Planning/ConcurrencyTests.swift @@ -0,0 +1,241 @@ +// +// ConcurrencyTests.swift +// SportsTimeTests +// +// Phase 10: Concurrency Tests +// Documents current thread-safety behavior for future refactoring reference. +// + +import Testing +import CoreLocation +@testable import SportsTime + +@Suite("Concurrency Tests", .serialized) +struct ConcurrencyTests { + + // MARK: - Test Fixtures + + private let calendar = Calendar.current + + /// Creates a date with specific year/month/day/hour + private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = 0 + return calendar.date(from: components)! + } + + /// Creates a stadium at a known location + private func makeStadium( + id: UUID = UUID(), + city: String, + lat: Double, + lon: Double, + sport: Sport = .mlb + ) -> Stadium { + Stadium( + id: id, + name: "\(city) Stadium", + city: city, + state: "ST", + latitude: lat, + longitude: lon, + capacity: 40000, + sport: sport + ) + } + + /// Creates a game at a stadium + private func makeGame( + id: UUID = UUID(), + stadiumId: UUID, + homeTeamId: UUID = UUID(), + awayTeamId: UUID = UUID(), + dateTime: Date, + sport: Sport = .mlb + ) -> Game { + Game( + id: id, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + stadiumId: stadiumId, + dateTime: dateTime, + sport: sport, + season: "2026" + ) + } + + /// Creates a PlanningRequest for Scenario A (date range only) + private func makeScenarioARequest( + startDate: Date, + endDate: Date, + games: [Game], + stadiums: [UUID: Stadium] + ) -> PlanningRequest { + let preferences = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + numberOfDrivers: 1, + maxDrivingHoursPerDriver: 8.0, + allowRepeatCities: true + ) + + return PlanningRequest( + preferences: preferences, + availableGames: games, + teams: [:], + stadiums: stadiums + ) + } + + /// Creates a valid test request with nearby cities + private func makeValidTestRequest(requestIndex: Int) -> PlanningRequest { + // Use different but nearby city pairs for each request to create variety + let cityPairs: [(city1: (String, Double, Double), city2: (String, Double, Double))] = [ + (("Chicago", 41.8781, -87.6298), ("Milwaukee", 43.0389, -87.9065)), + (("New York", 40.7128, -73.9352), ("Philadelphia", 39.9526, -75.1652)), + (("Boston", 42.3601, -71.0589), ("Providence", 41.8240, -71.4128)), + (("Los Angeles", 34.0522, -118.2437), ("San Diego", 32.7157, -117.1611)), + (("Seattle", 47.6062, -122.3321), ("Portland", 45.5152, -122.6784)), + ] + + let pair = cityPairs[requestIndex % cityPairs.count] + + let stadium1Id = UUID() + let stadium2Id = UUID() + + let stadium1 = makeStadium(id: stadium1Id, city: pair.city1.0, lat: pair.city1.1, lon: pair.city1.2) + let stadium2 = makeStadium(id: stadium2Id, city: pair.city2.0, lat: pair.city2.1, lon: pair.city2.2) + + let stadiums = [stadium1Id: stadium1, stadium2Id: stadium2] + + // Games on different days for feasible routing + let baseDay = 5 + (requestIndex * 2) % 20 + let game1 = makeGame(stadiumId: stadium1Id, dateTime: makeDate(day: baseDay, hour: 19)) + let game2 = makeGame(stadiumId: stadium2Id, dateTime: makeDate(day: baseDay + 2, hour: 19)) + + return makeScenarioARequest( + startDate: makeDate(day: baseDay - 1, hour: 0), + endDate: makeDate(day: baseDay + 5, hour: 23), + games: [game1, game2], + stadiums: stadiums + ) + } + + // MARK: - 10.1: Concurrent Requests Test + + @Test("10.1 - Concurrent requests behavior documentation") + func test_engine_ConcurrentRequests_CurrentlyUnsafe() async { + // DOCUMENTATION TEST + // Purpose: Document the current behavior when TripPlanningEngine is called concurrently. + // + // Current Implementation Status: + // - TripPlanningEngine is a `final class` (not an actor) + // - It appears stateless - no mutable instance state persists between calls + // - Each call to planItineraries creates fresh planner instances + // + // Expected Behavior: + // - If truly stateless: concurrent calls should succeed independently + // - If hidden state exists: may see race conditions or crashes + // + // This test documents the current behavior for future refactoring reference. + + let concurrentRequestCount = 10 + let engine = TripPlanningEngine() + + // Create unique requests for each concurrent task + let requests = (0..