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>
1099 lines
51 KiB
Swift
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)
|
|
}
|
|
}
|