Files
Sportstime/SportsTimeTests/Planning/ScenarioEPlannerRealDataTest.swift
2026-04-03 15:30:54 -05:00

242 lines
9.9 KiB
Swift

//
// 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<String> = ["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 {}