// // SuggestedTripsGeneratorTests.swift // SportsTimeTests // // TDD specification tests for SuggestedTripsGenerator types. // import Testing import Foundation @testable import SportsTime // MARK: - SuggestedTrip Tests @Suite("SuggestedTrip") struct SuggestedTripTests { // MARK: - Test Data private func makeTrip() -> Trip { Trip( name: "Test Trip", preferences: TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: Date(), endDate: Date().addingTimeInterval(86400 * 7), leisureLevel: .moderate ), stops: [], travelSegments: [], totalGames: 3, totalDistanceMeters: 1000, totalDrivingSeconds: 3600 ) } private func makeSuggestedTrip( region: Region = .east, isSingleSport: Bool = true, sports: Set = [.mlb] ) -> SuggestedTrip { SuggestedTrip( id: UUID(), region: region, isSingleSport: isSingleSport, trip: makeTrip(), richGames: [:], sports: sports ) } // MARK: - Specification Tests: displaySports /// - Expected Behavior: Returns sorted array of sports @Test("displaySports: returns sorted sports array") func displaySports_sorted() { let suggested = makeSuggestedTrip(sports: [.nhl, .mlb, .nba]) let display = suggested.displaySports #expect(display.count == 3) // Sports should be sorted by rawValue let sortedExpected = [Sport.mlb, .nba, .nhl].sorted { $0.rawValue < $1.rawValue } #expect(display == sortedExpected) } @Test("displaySports: single sport returns array of one") func displaySports_singleSport() { let suggested = makeSuggestedTrip(sports: [.mlb]) #expect(suggested.displaySports.count == 1) #expect(suggested.displaySports.first == .mlb) } @Test("displaySports: empty sports returns empty array") func displaySports_empty() { let suggested = makeSuggestedTrip(sports: []) #expect(suggested.displaySports.isEmpty) } // MARK: - Specification Tests: sportLabel /// - Expected Behavior: Single sport returns sport rawValue @Test("sportLabel: returns sport name for single sport") func sportLabel_singleSport() { let suggested = makeSuggestedTrip(sports: [.mlb]) #expect(suggested.sportLabel == "MLB") } /// - Expected Behavior: Multiple sports returns "Multi-Sport" @Test("sportLabel: returns 'Multi-Sport' for multiple sports") func sportLabel_multipleSports() { let suggested = makeSuggestedTrip(sports: [.mlb, .nba]) #expect(suggested.sportLabel == "Multi-Sport") } @Test("sportLabel: returns 'Multi-Sport' for three sports") func sportLabel_threeSports() { let suggested = makeSuggestedTrip(sports: [.mlb, .nba, .nhl]) #expect(suggested.sportLabel == "Multi-Sport") } /// - Expected Behavior: Empty sports returns "Multi-Sport" (no single sport to display) @Test("sportLabel: returns Multi-Sport for no sports") func sportLabel_noSports() { let suggested = makeSuggestedTrip(sports: []) #expect(suggested.sportLabel == "Multi-Sport") } // MARK: - Specification Tests: Properties @Test("SuggestedTrip: stores region") func suggestedTrip_region() { let suggested = makeSuggestedTrip(region: .west) #expect(suggested.region == .west) } @Test("SuggestedTrip: stores isSingleSport") func suggestedTrip_isSingleSport() { let single = makeSuggestedTrip(isSingleSport: true) let multi = makeSuggestedTrip(isSingleSport: false) #expect(single.isSingleSport == true) #expect(multi.isSingleSport == false) } // MARK: - Invariant Tests /// - Invariant: sports.count == 1 implies sportLabel is sport rawValue (uppercase) @Test("Invariant: single sport implies specific label") func invariant_singleSportImpliesSpecificLabel() { let singleSports: [Sport] = [.mlb, .nba, .nhl, .nfl] for sport in singleSports { let suggested = makeSuggestedTrip(sports: [sport]) if suggested.sports.count == 1 { #expect(suggested.sportLabel == sport.rawValue) // rawValue is uppercase (e.g., "MLB") } } } /// - Invariant: sports.count > 1 implies sportLabel is "Multi-Sport" @Test("Invariant: multiple sports implies Multi-Sport label") func invariant_multipleSportsImpliesMultiSportLabel() { let suggested = makeSuggestedTrip(sports: [.mlb, .nba]) if suggested.sports.count > 1 { #expect(suggested.sportLabel == "Multi-Sport") } } /// - Invariant: displaySports.count == sports.count @Test("Invariant: displaySports count matches sports count") func invariant_displaySportsCountMatchesSportsCount() { let testCases: [Set] = [ [], [.mlb], [.mlb, .nba], [.mlb, .nba, .nhl] ] for sports in testCases { let suggested = makeSuggestedTrip(sports: sports) #expect(suggested.displaySports.count == sports.count) } } } // MARK: - Haversine Distance Tests @Suite("Haversine Distance") struct HaversineDistanceTests { // Note: haversineDistance is a private static function in SuggestedTripsGenerator // These tests document the expected behavior for distance calculations // MARK: - Specification Tests: Known Distances /// - Expected Behavior: Distance between same points is 0 @Test("Distance: same point returns 0") func distance_samePoint() { // New York to New York let distance = calculateHaversine( lat1: 40.7128, lon1: -74.0060, lat2: 40.7128, lon2: -74.0060 ) #expect(distance == 0) } /// - Expected Behavior: NYC to LA is approximately 2,450 miles @Test("Distance: NYC to LA approximately 2450 miles") func distance_nycToLa() { // New York: 40.7128, -74.0060 // Los Angeles: 34.0522, -118.2437 let distance = calculateHaversine( lat1: 40.7128, lon1: -74.0060, lat2: 34.0522, lon2: -118.2437 ) // Allow 5% tolerance #expect(distance > 2300 && distance < 2600) } /// - Expected Behavior: NYC to Boston is approximately 190 miles @Test("Distance: NYC to Boston approximately 190 miles") func distance_nycToBoston() { // New York: 40.7128, -74.0060 // Boston: 42.3601, -71.0589 let distance = calculateHaversine( lat1: 40.7128, lon1: -74.0060, lat2: 42.3601, lon2: -71.0589 ) // Allow 10% tolerance #expect(distance > 170 && distance < 220) } // MARK: - Invariant Tests /// - Invariant: Distance is symmetric (A to B == B to A) @Test("Invariant: distance is symmetric") func invariant_symmetric() { let distanceAB = calculateHaversine( lat1: 40.7128, lon1: -74.0060, lat2: 34.0522, lon2: -118.2437 ) let distanceBA = calculateHaversine( lat1: 34.0522, lon1: -118.2437, lat2: 40.7128, lon2: -74.0060 ) #expect(abs(distanceAB - distanceBA) < 0.001) } /// - Invariant: Distance is always non-negative @Test("Invariant: distance is non-negative") func invariant_nonNegative() { let testCases: [(lat1: Double, lon1: Double, lat2: Double, lon2: Double)] = [ (0, 0, 0, 0), (40.0, -74.0, 34.0, -118.0), (-33.9, 151.2, 51.5, -0.1), // Sydney to London (90, 0, -90, 0) // North to South pole ] for (lat1, lon1, lat2, lon2) in testCases { let distance = calculateHaversine(lat1: lat1, lon1: lon1, lat2: lat2, lon2: lon2) #expect(distance >= 0) } } // MARK: - Test Helper (mirrors implementation) private func calculateHaversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { let R = 3959.0 // Earth radius in miles let dLat = (lat2 - lat1) * .pi / 180 let dLon = (lon2 - lon1) * .pi / 180 let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) * sin(dLon/2) * sin(dLon/2) let c = 2 * atan2(sqrt(a), sqrt(1-a)) return R * c } } // MARK: - Cross-Country Feature Trip Tests @Suite("SuggestedTripsGenerator Cross-Country") struct CrossCountryFeatureTripTests { private struct CitySeed { let name: String let state: String let latitude: Double let longitude: Double } // 20 known US cities spanning east/central/west regions. private let citySeeds: [CitySeed] = [ CitySeed(name: "Boston", state: "MA", latitude: 42.3601, longitude: -71.0589), CitySeed(name: "New York", state: "NY", latitude: 40.7128, longitude: -74.0060), CitySeed(name: "Philadelphia", state: "PA", latitude: 39.9526, longitude: -75.1652), CitySeed(name: "Baltimore", state: "MD", latitude: 39.2904, longitude: -76.6122), CitySeed(name: "Washington", state: "DC", latitude: 38.9072, longitude: -77.0369), CitySeed(name: "Charlotte", state: "NC", latitude: 35.2271, longitude: -80.8431), CitySeed(name: "Atlanta", state: "GA", latitude: 33.7490, longitude: -84.3880), CitySeed(name: "Nashville", state: "TN", latitude: 36.1627, longitude: -86.7816), CitySeed(name: "St Louis", state: "MO", latitude: 38.6270, longitude: -90.1994), CitySeed(name: "Chicago", state: "IL", latitude: 41.8781, longitude: -87.6298), CitySeed(name: "Minneapolis", state: "MN", latitude: 44.9778, longitude: -93.2650), CitySeed(name: "Kansas City", state: "MO", latitude: 39.0997, longitude: -94.5786), CitySeed(name: "Dallas", state: "TX", latitude: 32.7767, longitude: -96.7970), CitySeed(name: "Denver", state: "CO", latitude: 39.7392, longitude: -104.9903), CitySeed(name: "Albuquerque", state: "NM", latitude: 35.0844, longitude: -106.6504), CitySeed(name: "Phoenix", state: "AZ", latitude: 33.4484, longitude: -112.0740), CitySeed(name: "Las Vegas", state: "NV", latitude: 36.1699, longitude: -115.1398), CitySeed(name: "Los Angeles", state: "CA", latitude: 34.0522, longitude: -118.2437), CitySeed(name: "San Diego", state: "CA", latitude: 32.7157, longitude: -117.1611), CitySeed(name: "Seattle", state: "WA", latitude: 47.6062, longitude: -122.3321), ] private func canonicalToken(_ value: String) -> String { value .lowercased() .replacingOccurrences(of: " ", with: "_") .replacingOccurrences(of: ".", with: "") } private func makeStadium(from city: CitySeed) -> Stadium { let token = canonicalToken(city.name) return Stadium( id: "stadium_test_\(token)", name: "\(city.name) Test Stadium", city: city.name, state: city.state, latitude: city.latitude, longitude: city.longitude, capacity: 40000, sport: .mlb ) } private func makeTeams(for stadium: Stadium) -> [Team] { let token = canonicalToken(stadium.city) let home = Team( id: "team_test_home_\(token)", name: "\(stadium.city) Home", abbreviation: String(token.prefix(3)).uppercased(), sport: .mlb, city: stadium.city, stadiumId: stadium.id ) let away = Team( id: "team_test_away_\(token)", name: "\(stadium.city) Away", abbreviation: "A\(String(token.prefix(2)).uppercased())", sport: .mlb, city: stadium.city, stadiumId: stadium.id ) return [home, away] } private func makeGames( from stadiums: [Stadium], startDate: Date, spacingDays: Int = 1, idPrefix: String ) -> [Game] { var games: [Game] = [] let calendar = Calendar.current for (index, stadium) in stadiums.enumerated() { let gameDate = calendar.date(byAdding: .day, value: index * spacingDays, to: startDate) ?? startDate let token = canonicalToken(stadium.city) games.append( Game( id: "game_test_\(idPrefix)_\(token)_\(index)", homeTeamId: "team_test_home_\(token)", awayTeamId: "team_test_away_\(token)", stadiumId: stadium.id, dateTime: gameDate, sport: .mlb, season: "2026" ) ) } return games } private func makeDataset(spacingDays: Int = 1) -> (games: [Game], stadiumsById: [String: Stadium], teamsById: [String: Team]) { let stadiums = citySeeds.map(makeStadium) let teams = stadiums.flatMap(makeTeams) let sortedEastToWest = stadiums.sorted { $0.longitude > $1.longitude } let sortedWestToEast = Array(sortedEastToWest.reversed()) let baseDate = Date(timeIntervalSince1970: 1_736_000_000) // Fixed baseline for deterministic test behavior let eastToWestGames = makeGames( from: sortedEastToWest, startDate: baseDate, spacingDays: spacingDays, idPrefix: "e2w" ) let secondLegStart = Calendar.current.date( byAdding: .day, value: (sortedEastToWest.count * spacingDays) + 2, to: baseDate ) ?? baseDate let westToEastGames = makeGames( from: sortedWestToEast, startDate: secondLegStart, spacingDays: spacingDays, idPrefix: "w2e" ) let games = eastToWestGames + westToEastGames let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { partialResult, stadium in partialResult[stadium.id] = stadium } let teamsById = teams.reduce(into: [String: Team]()) { partialResult, team in partialResult[team.id] = team } return (games: games, stadiumsById: stadiumsById, teamsById: teamsById) } private func routeRegions(for trip: SuggestedTrip, stadiumsById: [String: Stadium]) -> Set { let gameIdsInTrip = Set(trip.trip.stops.flatMap(\.games)) let tripGames = trip.richGames.values .map(\.game) .filter { gameIdsInTrip.contains($0.id) } return Set(tripGames.compactMap { game in stadiumsById[game.stadiumId]?.region }) } @Test("Cross-country (20 cities): east-to-west generates a valid coast-to-coast trip") func crossCountry_eastToWest_fromTwentyCities() { let (games, stadiumsById, teamsById) = makeDataset() let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip( games: games, stadiums: stadiumsById, teams: teamsById, eastToWest: true ) #expect(trip != nil) guard let trip else { return } #expect(trip.region == .crossCountry) #expect(trip.trip.stops.count >= 3) #expect(trip.trip.totalGames >= 3) let regions = routeRegions(for: trip, stadiumsById: stadiumsById) #expect(regions.contains(.east)) #expect(regions.contains(.west)) } @Test("Cross-country (20 cities): west-to-east generates a valid coast-to-coast trip") func crossCountry_westToEast_fromTwentyCities() { let (games, stadiumsById, teamsById) = makeDataset() let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip( games: games, stadiums: stadiumsById, teams: teamsById, eastToWest: false ) #expect(trip != nil) guard let trip else { return } #expect(trip.region == .crossCountry) #expect(trip.trip.stops.count >= 3) #expect(trip.trip.totalGames >= 3) let regions = routeRegions(for: trip, stadiumsById: stadiumsById) #expect(regions.contains(.east)) #expect(regions.contains(.west)) } @Test("Cross-country performance: 20-city dataset stays under target average runtime") func crossCountry_generationPerformance_twentyCityDataset() { let (games, stadiumsById, teamsById) = makeDataset() let iterations = 20 var elapsedMillis: [Double] = [] for _ in 0..