Files
Sportstime/SportsTimeTests/Planning/PlannerOutputSanityTests.swift
Trey T 9b622f8bbb Harden planning test suite with realistic fixtures and output sanity checks
Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests,
and updates all scenario planner tests with improved coverage and assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:38:41 -05:00

1099 lines
51 KiB
Swift

//
// PlannerOutputSanityTests.swift
// SportsTimeTests
//
// Tests that planner outputs pass basic sanity checks any user would notice.
// These complement algorithmic correctness tests by verifying output invariants
// like "no past games", "no wrong sports", "stops in chronological order", etc.
//
import Testing
import Foundation
import CoreLocation
@testable import SportsTime
@Suite("PlannerOutputSanity")
struct PlannerOutputSanityTests {
// MARK: - Shared Helpers
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.9484, longitude: -87.6553)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
private let atlantaCoord = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400)
private func makeStadium(id: String, city: String, coordinate: CLLocationCoordinate2D, sport: Sport = .mlb) -> Stadium {
Stadium(id: id, name: "\(city) Stadium", city: city, state: "XX",
latitude: coordinate.latitude, longitude: coordinate.longitude,
capacity: 40000, sport: sport)
}
private func makeGame(id: String, homeTeamId: String, awayTeamId: String,
stadiumId: String, dateTime: Date, sport: Sport = .mlb) -> Game {
Game(id: id, homeTeamId: homeTeamId, awayTeamId: awayTeamId,
stadiumId: stadiumId, dateTime: dateTime, sport: sport,
season: "2026", isPlayoff: false)
}
private func makeTeam(id: String, name: String, city: String, stadiumId: String, sport: Sport = .mlb) -> Team {
Team(id: id, name: name, abbreviation: String(name.prefix(3)).uppercased(),
sport: sport, city: city, stadiumId: stadiumId)
}
/// Extracts all game IDs from a successful result.
private func allGameIds(from result: ItineraryResult) -> [Set<String>] {
guard case .success(let options) = result else { return [] }
return options.map { Set($0.stops.flatMap { $0.games }) }
}
/// Resolves Game objects for game IDs in a result against a game list.
private func resolvedGames(from result: ItineraryResult, allGames: [Game]) -> [[Game]] {
let gameMap = Dictionary(allGames.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
guard case .success(let options) = result else { return [] }
return options.map { option in
option.stops.flatMap { $0.games }.compactMap { gameMap[$0] }
}
}
// MARK: - Category 1: Temporal Sanity (no past games in output)
@Test("ScenarioA: future-only date range — all stops within range")
func scenarioA_pastGamesInRange_excludedFromResults() {
// Use absolute future dates to avoid TestClock.now vs Date() mismatch
let startDate = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 12)
let endDate = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 12)
let calendar = TestClock.calendar
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let g1 = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 7, day: 3, hour: 19))
let g2 = makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19))
let g3 = makeGame(id: "g3", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 7, day: 7, hour: 19))
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [g1, g2, g3],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty, "Should find options")
for option in options {
for stop in option.stops where !stop.games.isEmpty {
#expect(stop.arrivalDate >= calendar.startOfDay(for: startDate),
"Stop \(stop.city) arrival \(stop.arrivalDate) before startDate")
}
}
}
@Test("ScenarioE: all result stops are in the future")
func scenarioE_allResultStopsInFuture() {
// Regression test for the PHI/WSN/BAL bug where past spring training games appeared
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
let calendar = TestClock.calendar
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
// Past games (March spring training territory)
let pastGames = [
makeGame(id: "past-phi", homeTeamId: "phi", awayTeamId: "opp", stadiumId: "philly",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 5, hour: 13)),
makeGame(id: "past-nyc", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 7, hour: 13)),
makeGame(id: "past-bos", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 9, hour: 13)),
]
// Future games
let futureGames = [
makeGame(id: "future-phi", homeTeamId: "phi", awayTeamId: "opp", stadiumId: "philly",
dateTime: TestFixtures.date(year: 2026, month: 4, day: 10, hour: 19)),
makeGame(id: "future-nyc", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 4, day: 12, hour: 19)),
makeGame(id: "future-bos", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 4, day: 14, hour: 19)),
]
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["phi", "nyc_team", "bos_team"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: pastGames + futureGames,
teams: [
"phi": makeTeam(id: "phi", name: "Phillies", city: "Philadelphia", stadiumId: "philly"),
"nyc_team": makeTeam(id: "nyc_team", name: "Yankees", city: "New York", stadiumId: "nyc"),
"bos_team": makeTeam(id: "bos_team", name: "Red Sox", city: "Boston", stadiumId: "boston"),
],
stadiums: ["philly": phillyStadium, "nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
let startOfCurrentDay = calendar.startOfDay(for: currentDate)
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= startOfCurrentDay,
"Stop arrival \(stop.arrivalDate) should be on or after \(startOfCurrentDay)")
}
}
}
@Test("ScenarioD: follow team — past games in date range excluded")
func scenarioD_followTeam_pastGamesExcluded() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let pastGame = makeGame(id: "past-team-game", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: TestClock.addingDays(-3))
let futureGame1 = makeGame(id: "future-team-1", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let futureGame2 = makeGame(id: "future-team-2", homeTeamId: "opp", awayTeamId: "yankees",
stadiumId: "boston", dateTime: TestClock.addingDays(4))
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: TestClock.addingDays(-5),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastGame, futureGame1, futureGame2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc"),
"opp": makeTeam(id: "opp", name: "Opponent", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioDPlanner()
let result = planner.plan(request: request)
// ScenarioD filters by date range only it does NOT filter past games
// that fall within the user's specified date range. The past game at day-3
// is within [day-5, day+7] so it may appear in output.
// This test verifies stops are at least within the date range.
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for stop in option.stops {
if !stop.games.isEmpty {
#expect(stop.arrivalDate >= TestClock.calendar.startOfDay(for: TestClock.addingDays(-5)),
"Stop should not be before start date")
// Verify no game dates fall outside the date range
for gameId in stop.games {
if let game = [pastGame, futureGame1, futureGame2].first(where: { $0.id == gameId }) {
#expect(game.startTime <= TestClock.addingDays(7),
"Game \(gameId) should not be after end date")
}
}
}
}
}
}
@Test("All planners: output dates not before start date")
func allPlanners_outputDatesNotBeforeStartDate() {
let startDate = TestClock.addingDays(1)
let endDate = TestClock.addingDays(8)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly",
dateTime: TestClock.addingDays(6)),
]
let teams = [
"t1": makeTeam(id: "t1", name: "Team1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "Team2", city: "Boston", stadiumId: "boston"),
"t3": makeTeam(id: "t3", name: "Team3", city: "Philadelphia", stadiumId: "philly"),
]
let stadiums = ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
// Test ScenarioA
let prefsA = TripPreferences(
planningMode: .dateRange, sports: [.mlb],
startDate: startDate, endDate: endDate,
leisureLevel: .packed, numberOfDrivers: 2
)
let requestA = PlanningRequest(preferences: prefsA, availableGames: games, teams: teams, stadiums: stadiums)
let resultA = ScenarioAPlanner().plan(request: requestA)
guard case .success(let options) = resultA else { Issue.record("Expected .success, got \(resultA)"); return }
for option in options {
for stop in option.stops where !stop.games.isEmpty {
#expect(stop.arrivalDate >= startDate,
"ScenarioA: stop arrival \(stop.arrivalDate) before startDate \(startDate)")
}
}
}
// MARK: - Category 2: Sport Filtering
@Test("ScenarioA: games with missing stadiums filtered out — wrong sport stadiums absent")
func scenarioA_mixedSports_wrongSportGamesExcludedByMissingStadium() {
// Sport filtering happens upstream (AppDataProvider). At the planner level,
// wrong-sport games are excluded because their stadiums aren't in the map.
let nycMLB = makeStadium(id: "nyc_mlb", city: "New York", coordinate: nycCoord, sport: .mlb)
let bostonMLB = makeStadium(id: "boston_mlb", city: "Boston", coordinate: bostonCoord, sport: .mlb)
let mlbGame1 = makeGame(id: "mlb1", homeTeamId: "mlb_t1", awayTeamId: "vis",
stadiumId: "nyc_mlb", dateTime: TestClock.addingDays(2), sport: .mlb)
let mlbGame2 = makeGame(id: "mlb2", homeTeamId: "mlb_t2", awayTeamId: "vis",
stadiumId: "boston_mlb", dateTime: TestClock.addingDays(4), sport: .mlb)
// NBA game references a stadium NOT in the map (as would happen in production)
let nbaGame = makeGame(id: "nba1", homeTeamId: "nba_t1", awayTeamId: "vis",
stadiumId: "nyc_nba_missing", dateTime: TestClock.addingDays(3), sport: .nba)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2
)
// Only MLB stadiums in the map NBA stadium absent
let request = PlanningRequest(
preferences: prefs,
availableGames: [mlbGame1, mlbGame2, nbaGame],
teams: [:],
stadiums: ["nyc_mlb": nycMLB, "boston_mlb": bostonMLB]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
let ids = Set(option.stops.flatMap { $0.games })
#expect(!ids.contains("nba1"),
"NBA game with no stadium in map should not appear in output")
}
}
// MARK: - Category 3: Geographic / Data Integrity
@Test("All planners: no 'Unknown' cities in output")
func allPlanners_noUnknownCitiesInOutput() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for stop in option.stops {
#expect(stop.city != "Unknown",
"Stop should not have 'Unknown' city")
#expect(!stop.city.isEmpty,
"Stop city should not be empty")
}
}
}
@Test("All planners: no nil coordinates in stops")
func allPlanners_noNilCoordinatesInStops() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for stop in option.stops where !stop.games.isEmpty {
#expect(stop.coordinate != nil,
"Stop in \(stop.city) should have coordinates")
}
}
}
@Test("ScenarioA: game with missing stadium — excluded, not crash")
func scenarioA_gameWithMissingStadium_excludedNotCrash() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
// One game has a valid stadium, one has a stadium not in the map
let validGame = makeGame(id: "valid", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let orphanGame = makeGame(id: "orphan", homeTeamId: "t2", awayTeamId: "vis",
stadiumId: "stadium_nonexistent", dateTime: TestClock.addingDays(3))
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [validGame, orphanGame],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Nowhere", stadiumId: "stadium_nonexistent")],
stadiums: ["nyc": nycStadium] // Only NYC stadium in map
)
// Should not crash orphan game is simply excluded
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
let ids = Set(option.stops.flatMap { $0.games })
#expect(!ids.contains("orphan"),
"Game with missing stadium should not appear in output")
}
}
// MARK: - Category 4: Duplicate Prevention
@Test("All planners: no duplicate game IDs in same stop")
func allPlanners_noDuplicateGameIdsInSameStop() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create games that could plausibly overlap
let g1 = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let g2 = makeGame(id: "g2", homeTeamId: "t1", awayTeamId: "vis2",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let g3 = makeGame(id: "g3", homeTeamId: "t2", awayTeamId: "vis",
stadiumId: "boston", dateTime: TestClock.addingDays(4))
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [g1, g2, g3],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for stop in option.stops {
let ids = stop.games
let uniqueIds = Set(ids)
#expect(ids.count == uniqueIds.count,
"Stop in \(stop.city) has duplicate game IDs: \(ids)")
}
}
}
@Test("All planners: no duplicate game IDs across stops")
func allPlanners_noDuplicateGameIdsAcrossStops() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly",
dateTime: TestClock.addingDays(6)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(8),
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"),
"t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (idx, option) in options.enumerated() {
var seen = Set<String>()
for stop in option.stops {
for gid in stop.games {
#expect(!seen.contains(gid),
"Option \(idx): game \(gid) appears in multiple stops")
seen.insert(gid)
}
}
}
}
// MARK: - Category 5: Chronological Order
@Test("All planners: stops chronologically ordered")
func allPlanners_stopsChronologicallyOrdered() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly",
dateTime: TestClock.addingDays(6)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(8),
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"),
"t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (idx, option) in options.enumerated() {
for i in 0..<(option.stops.count - 1) {
#expect(option.stops[i].arrivalDate <= option.stops[i + 1].arrivalDate,
"Option \(idx): stops not chronological — \(option.stops[i].city) arrives \(option.stops[i].arrivalDate) but next stop \(option.stops[i+1].city) arrives \(option.stops[i+1].arrivalDate)")
}
}
}
@Test("ScenarioD: follow team — games within stop are chronological")
func scenarioD_followTeam_gamesWithinStopChronological() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Two games at same stadium on same day (doubleheader)
let game1 = makeGame(id: "dh1", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let game2 = makeGame(id: "dh2", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestClock.addingHours(48 + 4)) // 4 hours later same day
let game3 = makeGame(id: "away1", homeTeamId: "opp", awayTeamId: "yankees",
stadiumId: "boston", dateTime: TestClock.addingDays(4))
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game1, game2, game3],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc"),
"opp": makeTeam(id: "opp", name: "Opponent", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioDPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
let allGames = [game1, game2, game3]
let gameMap = Dictionary(allGames.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f })
for option in options {
for stop in option.stops where stop.games.count > 1 {
let gameDates = stop.games.compactMap { gameMap[$0]?.dateTime }
for i in 0..<(gameDates.count - 1) {
#expect(gameDates[i] <= gameDates[i + 1],
"Games within stop should be chronological")
}
}
}
}
@Test("ScenarioE: results span multiple months — not all clustered")
func scenarioE_windowsSpreadAcrossSeason() {
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
let calendar = TestClock.calendar
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Games every 10 days for 6 months
var games: [Game] = []
for monthOffset in 0..<6 {
let month = 4 + monthOffset
for dayOffset in stride(from: 1, through: 25, by: 10) {
let dt = TestFixtures.date(year: 2026, month: month, day: dayOffset, hour: 19)
games.append(makeGame(id: "nyc-\(month)-\(dayOffset)", homeTeamId: "nyc_team",
awayTeamId: "opp", stadiumId: "nyc", dateTime: dt))
let dt2 = calendar.date(byAdding: .day, value: 1, to: dt)!
games.append(makeGame(id: "bos-\(month)-\(dayOffset)", homeTeamId: "bos_team",
awayTeamId: "opp", stadiumId: "boston", dateTime: dt2))
}
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["nyc_team", "bos_team"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["nyc_team": makeTeam(id: "nyc_team", name: "NYT", city: "New York", stadiumId: "nyc"),
"bos_team": makeTeam(id: "bos_team", name: "BST", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
let months = Set(options.flatMap { option in
option.stops.map { calendar.component(.month, from: $0.arrivalDate) }
})
#expect(months.count >= 2,
"Results should span at least 2 months, got: \(months.sorted())")
}
// MARK: - Category 6: Constraint Adherence
@Test("All planners: repeat cities false — no city appears twice")
func allPlanners_repeatCitiesFalse_noCityAppearsTwice() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
// Create games that revisit NYC
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
makeGame(id: "g3", homeTeamId: "t3", awayTeamId: "vis", stadiumId: "philly",
dateTime: TestClock.addingDays(5)),
makeGame(id: "g4", homeTeamId: "t1", awayTeamId: "vis2", stadiumId: "nyc",
dateTime: TestClock.addingDays(6)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(8),
leisureLevel: .packed,
numberOfDrivers: 2,
allowRepeatCities: false
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"),
"t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
// Run through the full engine (which applies repeat-city filtering)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (idx, option) in options.enumerated() {
let cities = option.stops.map(\.city)
let uniqueCities = Set(cities)
#expect(cities.count == uniqueCities.count,
"Option \(idx): repeat cities found when disallowed — \(cities)")
}
}
@Test("ScenarioA: trip duration does not exceed date range")
func scenarioA_tripDurationDoesNotExceedDateRange() {
let startDate = TestClock.addingDays(1)
let endDate = TestClock.addingDays(6)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let games = [
makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "g2", homeTeamId: "t2", awayTeamId: "vis", stadiumId: "boston",
dateTime: TestClock.addingDays(4)),
]
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
// Allow 1 day buffer for departure on last day
let maxDeparture = TestClock.calendar.date(byAdding: .day, value: 1, to: endDate)!
for (idx, option) in options.enumerated() {
if let lastStop = option.stops.last {
#expect(lastStop.departureDate <= maxDeparture,
"Option \(idx): last departure \(lastStop.departureDate) exceeds endDate+1 \(maxDeparture)")
}
}
}
@Test("ScenarioB: all anchor games present in every option")
func scenarioB_allAnchorGamesPresent() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let anchor1 = makeGame(id: "anchor1", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let anchor2 = makeGame(id: "anchor2", homeTeamId: "t3", awayTeamId: "vis",
stadiumId: "philly", dateTime: TestClock.addingDays(5))
let bonus = makeGame(id: "bonus", homeTeamId: "t2", awayTeamId: "vis",
stadiumId: "boston", dateTime: TestClock.addingDays(3))
let prefs = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: ["anchor1", "anchor2"],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [anchor1, anchor2, bonus],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc"),
"t2": makeTeam(id: "t2", name: "T2", city: "Boston", stadiumId: "boston"),
"t3": makeTeam(id: "t3", name: "T3", city: "Philadelphia", stadiumId: "philly")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = ScenarioBPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (idx, option) in options.enumerated() {
let allIds = Set(option.stops.flatMap { $0.games })
#expect(allIds.contains("anchor1"),
"Option \(idx): missing anchor game 'anchor1'")
#expect(allIds.contains("anchor2"),
"Option \(idx): missing anchor game 'anchor2'")
}
}
@Test("ScenarioE: all selected teams represented in every option")
func scenarioE_allSelectedTeamsRepresented() {
let currentDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let games = [
makeGame(id: "nyc1", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestClock.addingDays(2)),
makeGame(id: "bos1", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestClock.addingDays(3)),
makeGame(id: "phi1", homeTeamId: "phi_team", awayTeamId: "opp", stadiumId: "philly",
dateTime: TestClock.addingDays(4)),
// More spread-out games
makeGame(id: "nyc2", homeTeamId: "nyc_team", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestClock.addingDays(10)),
makeGame(id: "bos2", homeTeamId: "bos_team", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestClock.addingDays(12)),
makeGame(id: "phi2", homeTeamId: "phi_team", awayTeamId: "opp", stadiumId: "philly",
dateTime: TestClock.addingDays(14)),
]
let selectedTeamIds: Set<String> = ["nyc_team", "bos_team", "phi_team"]
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: selectedTeamIds
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [
"nyc_team": makeTeam(id: "nyc_team", name: "NYT", city: "New York", stadiumId: "nyc"),
"bos_team": makeTeam(id: "bos_team", name: "BST", city: "Boston", stadiumId: "boston"),
"phi_team": makeTeam(id: "phi_team", name: "PHI", city: "Philadelphia", stadiumId: "philly"),
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f })
for (idx, option) in options.enumerated() {
let homeTeamsInOption = Set(
option.stops.flatMap { $0.games }
.compactMap { gameMap[$0]?.homeTeamId }
)
for teamId in selectedTeamIds {
#expect(homeTeamsInOption.contains(teamId),
"Option \(idx): missing home game for team \(teamId)")
}
}
}
// MARK: - Category 7: Edge Cases Users Actually Hit
@Test("ScenarioA: single game in range — returns valid trip")
func scenarioA_singleGameInRange_returnsValidTrip() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let singleGame = makeGame(id: "only_game", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(3))
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [singleGame],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")],
stadiums: ["nyc": nycStadium]
)
// Should not crash. May return success with 1 stop or failure (no route with only 1 game)
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty, "Single game should produce at least one option")
for option in options {
#expect(option.stops.count >= 1, "Should have at least 1 stop")
}
}
@Test("ScenarioE: two teams same city — handled correctly")
func scenarioE_twoTeamsSameCity_handledCorrectly() {
let currentDate = TestClock.now
// Yankees and Mets both in NYC but different stadiums
let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
let citiFieldCoord = CLLocationCoordinate2D(latitude: 40.7571, longitude: -73.8458)
let yankeeStadium = makeStadium(id: "ys", city: "New York", coordinate: yankeeStadiumCoord)
let citiField = makeStadium(id: "cf", city: "New York", coordinate: citiFieldCoord)
let yankeesGame = makeGame(id: "ynk1", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "ys", dateTime: TestClock.addingDays(2))
let metsGame = makeGame(id: "met1", homeTeamId: "mets", awayTeamId: "opp",
stadiumId: "cf", dateTime: TestClock.addingDays(5))
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "mets"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, metsGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "ys"),
"mets": makeTeam(id: "mets", name: "Mets", city: "New York", stadiumId: "cf"),
],
stadiums: ["ys": yankeeStadium, "cf": citiField]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
// Should not crash. Both teams should be represented.
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty, "Same-city teams should produce valid routes")
for option in options {
let allIds = Set(option.stops.flatMap { $0.games })
#expect(allIds.contains("ynk1"), "Should include Yankees game")
#expect(allIds.contains("met1"), "Should include Mets game")
}
}
@Test("ScenarioD: team plays doubleheader — one stop, two games")
func scenarioD_teamPlaysDoubleheader_handledCorrectly() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let calendar = TestClock.calendar
let gameDay = TestClock.addingDays(2)
let gameTime1 = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: gameDay)!
let gameTime2 = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: gameDay)!
let game1 = makeGame(id: "dh_game1", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: gameTime1)
let game2 = makeGame(id: "dh_game2", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: gameTime2)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(5),
leisureLevel: .packed,
numberOfDrivers: 1,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game1, game2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees", city: "New York", stadiumId: "nyc")],
stadiums: ["nyc": nycStadium]
)
let result = ScenarioDPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
// Both games at same stadium should be in 1 stop, not 2 separate stops
let nycStops = option.stops.filter { $0.city == "New York" }
#expect(nycStops.count <= 1,
"Doubleheader games should be combined into 1 stop, got \(nycStops.count)")
if let nycStop = nycStops.first {
#expect(nycStop.games.count >= 2,
"NYC stop should contain both doubleheader games")
}
}
}
@Test("ScenarioC: start and end same city — handled gracefully")
func scenarioC_startAndEndSameCity_handledGracefully() {
let chicagoStadium = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let game = makeGame(id: "g1", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(3))
let chicagoLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: chicagoLocation,
endLocation: chicagoLocation, // Same as start round trip
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")],
stadiums: ["chi": chicagoStadium, "nyc": nycStadium]
)
// Should not crash. Either returns circular route or graceful failure.
let result = ScenarioCPlanner().plan(request: request)
switch result {
case .success(let options):
#expect(!options.isEmpty)
case .failure:
break // Failure is acceptable for round-trip edge case
}
}
@Test("ScenarioB: all selected games in same city — one stop")
func scenarioB_allSelectedGamesInSameCity_oneStop() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let g1 = makeGame(id: "nyc_g1", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let g2 = makeGame(id: "nyc_g2", homeTeamId: "t1", awayTeamId: "vis2",
stadiumId: "nyc", dateTime: TestClock.addingDays(3))
let g3 = makeGame(id: "nyc_g3", homeTeamId: "t1", awayTeamId: "vis3",
stadiumId: "nyc", dateTime: TestClock.addingDays(4))
let prefs = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: ["nyc_g1", "nyc_g2", "nyc_g3"],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
leisureLevel: .packed,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [g1, g2, g3],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")],
stadiums: ["nyc": nycStadium]
)
let result = ScenarioBPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
// All games are at the same stadium, so should be 1 stop (or at most 1 game stop)
let gameStops = option.stops.filter { !$0.games.isEmpty }
// They may be grouped into 1 stop or separated by day but city should be same
let cities = Set(gameStops.map(\.city))
#expect(cities.count <= 1,
"All games at same stadium should be in same city, got: \(cities)")
}
}
@Test("ScenarioA: same start and end date — finds games on that day")
func scenarioA_sameDayDateRange_findsGamesOnThatDay() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let gameDay = TestClock.addingDays(2)
let sameDay = makeGame(id: "sd1", homeTeamId: "t1", awayTeamId: "vis",
stadiumId: "nyc", dateTime: gameDay)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: gameDay,
endDate: gameDay, // Same day
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [sameDay],
teams: ["t1": makeTeam(id: "t1", name: "T1", city: "New York", stadiumId: "nyc")],
stadiums: ["nyc": nycStadium]
)
// Should not crash. May find the game or may fail gracefully.
let result = ScenarioAPlanner().plan(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty)
}
}