// // ScenarioEPlannerRealDataTest.swift // SportsTimeTests // // Regression test: runs ScenarioEPlanner with real PHI/WSN/BAL data // to verify the past-date filtering and full-season coverage fixes. // import Foundation import Testing import CoreLocation @testable import SportsTime @Suite("ScenarioE Real Data Regression") struct ScenarioEPlannerRealDataTest { @Test("PHI + WSN + BAL: returns future regular season results, not spring training") func phiWsnBal_returnsFutureRegularSeasonResults() throws { let fixture = try FixtureLoader.loadCanonicalGames() let rawGames = fixture.games // Filter to MLB only let mlbRows = rawGames.filter { $0.sport?.lowercased() == "mlb" } let games = mlbRows.compactMap(\.domainGame) let skippedMLBRows = mlbRows.count - games.count #expect(!games.isEmpty, "Expected MLB canonical fixture to contain valid games") // Team IDs let teamIds: Set = ["team_mlb_phi", "team_mlb_wsn", "team_mlb_bal"] // Build stadium map with real coordinates let stadiums: [String: Stadium] = [ "stadium_mlb_citizens_bank_park": Stadium( id: "stadium_mlb_citizens_bank_park", name: "Citizens Bank Park", city: "Philadelphia", state: "PA", latitude: 39.9061, longitude: -75.1665, capacity: 43035, sport: .mlb ), "stadium_mlb_nationals_park": Stadium( id: "stadium_mlb_nationals_park", name: "Nationals Park", city: "Washington", state: "DC", latitude: 38.8730, longitude: -77.0074, capacity: 41339, sport: .mlb ), "stadium_mlb_oriole_park_at_camden_yards": Stadium( id: "stadium_mlb_oriole_park_at_camden_yards", name: "Oriole Park at Camden Yards", city: "Baltimore", state: "MD", latitude: 39.2838, longitude: -76.6216, capacity: 45971, sport: .mlb ), // Spring training stadiums (should be excluded by date filter) "stadium_mlb_spring_baycare_ballpark": Stadium( id: "stadium_mlb_spring_baycare_ballpark", name: "BayCare Ballpark", city: "Clearwater", state: "FL", latitude: 27.9781, longitude: -82.7337, capacity: 8500, sport: .mlb ), "stadium_mlb_spring_cacti_park": Stadium( id: "stadium_mlb_spring_cacti_park", name: "CACTI Park", city: "West Palm Beach", state: "FL", latitude: 26.7367, longitude: -80.1197, capacity: 6671, sport: .mlb ), "stadium_mlb_spring_ed_smith_stadium": Stadium( id: "stadium_mlb_spring_ed_smith_stadium", name: "Ed Smith Stadium", city: "Sarasota", state: "FL", latitude: 27.3381, longitude: -82.5226, capacity: 8500, sport: .mlb ), ] // Build team map let teams: [String: Team] = [ "team_mlb_phi": Team(id: "team_mlb_phi", name: "Phillies", abbreviation: "PHI", sport: .mlb, city: "Philadelphia", stadiumId: "stadium_mlb_citizens_bank_park", primaryColor: "#E81828", secondaryColor: "#002D72"), "team_mlb_wsn": Team(id: "team_mlb_wsn", name: "Nationals", abbreviation: "WSN", sport: .mlb, city: "Washington", stadiumId: "stadium_mlb_nationals_park", primaryColor: "#AB0003", secondaryColor: "#14225A"), "team_mlb_bal": Team(id: "team_mlb_bal", name: "Orioles", abbreviation: "BAL", sport: .mlb, city: "Baltimore", stadiumId: "stadium_mlb_oriole_park_at_camden_yards", primaryColor: "#DF4601", secondaryColor: "#000000"), ] let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedTeamIds: teamIds ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: teams, stadiums: stadiums ) // Use April 2, 2026 as current date (the date the bug was reported) let currentDate = TestFixtures.date(year: 2026, month: 4, day: 2, hour: 12) let planner = ScenarioEPlanner(currentDate: currentDate) let result = planner.plan(request: request) guard case .success(let options) = result else { if case .failure(let failure) = result { Issue.record("Expected success but got failure: \(failure.reason) — \(failure.violations.map(\.description))") } return } #expect(!options.isEmpty, "Should find trip options for PHI/WSN/BAL") let calendar = Calendar.current var output = "\nRESULT> Fixture: \(fixture.url.path)\n" output += "RESULT> MLB rows: \(mlbRows.count), skipped malformed MLB rows: \(skippedMLBRows)\n" output += "RESULT> ========================================\n" output += "RESULT> PHI + WSN + BAL TRIP OPTIONS (\(options.count) results)\n" output += "RESULT> ========================================\n\n" for option in options { let cities = option.stops.map { "\($0.city)" }.joined(separator: " → ") let dates = option.stops.map { stop in let formatter = DateFormatter() formatter.dateFormat = "MMM d" return formatter.string(from: stop.arrivalDate) }.joined(separator: " → ") let gameIds = option.stops.flatMap { $0.games } let miles = Int(option.totalDistanceMiles) let hours = String(format: "%.1f", option.totalDrivingHours) output += "RESULT> Option #\(option.rank): \(cities)\n" output += "RESULT> Dates: \(dates)\n" output += "RESULT> Driving: \(miles) mi, \(hours) hrs\n" output += "RESULT> Games: \(gameIds.count)\n" output += "RESULT> Rationale: \(option.geographicRationale)\n" // Verify all games are in the future for stop in option.stops { #expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate), "Stop in \(stop.city) on \(stop.arrivalDate) should be after April 2, 2026") } // Verify no spring training stadiums for stop in option.stops { let isSpringTraining = stop.city == "Clearwater" || stop.city == "Sarasota" || stop.city == "West Palm Beach" #expect(!isSpringTraining, "Should not include spring training city: \(stop.city)") } output += "\n" } // Verify temporal spread — results should not all be in April let months = Set(options.flatMap { $0.stops.map { calendar.component(.month, from: $0.arrivalDate) } }) output += "RESULT> Months covered: \(months.sorted().map { DateFormatter().monthSymbols[$0 - 1] })\n" FileHandle.standardOutput.write(Data(output.utf8)) #expect(months.count >= 2, "Results should span multiple months across the season") } } // MARK: - JSON Decoding Helpers private struct CanonicalGameJSON: Decodable { let canonical_id: String? let sport: String? let season: String? let game_datetime_utc: String? let home_team_canonical_id: String? let away_team_canonical_id: String? let stadium_canonical_id: String? let is_playoff: Bool? var parsedDate: Date { guard let game_datetime_utc else { return Date.distantPast } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: game_datetime_utc) { return date } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: game_datetime_utc) ?? Date.distantPast } var domainGame: Game? { guard let canonical_id, let home_team_canonical_id, let away_team_canonical_id, let stadium_canonical_id, let season else { return nil } return Game( id: canonical_id, homeTeamId: home_team_canonical_id, awayTeamId: away_team_canonical_id, stadiumId: stadium_canonical_id, dateTime: parsedDate, sport: .mlb, season: season, isPlayoff: is_playoff ?? false ) } } private enum FixtureLoader { struct LoadedFixture { let url: URL let games: [CanonicalGameJSON] } static func loadCanonicalGames() throws -> LoadedFixture { let candidateURLs = [ repositoryRoot.appendingPathComponent("sportstime_export/games_canonical.json"), Bundle(for: BundleToken.self).url(forResource: "games_canonical", withExtension: "json") ].compactMap { $0 } for url in candidateURLs where FileManager.default.fileExists(atPath: url.path) { let data = try Data(contentsOf: url) let games = try JSONDecoder().decode([CanonicalGameJSON].self, from: data) return LoadedFixture(url: url, games: games) } throw FixtureLoadError.notFound(candidateURLs.map(\.path)) } private static var repositoryRoot: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() } } private enum FixtureLoadError: LocalizedError { case notFound([String]) var errorDescription: String? { switch self { case .notFound(let paths): let joined = paths.joined(separator: ", ") return "Could not find games_canonical.json. Checked: \(joined)" } } } private class BundleToken {}