Files
Sportstime/SportsTimeTests/Planning/ScenarioEPlannerTests.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

1587 lines
63 KiB
Swift

//
// ScenarioEPlannerTests.swift
// SportsTimeTests
//
// TDD specification tests for ScenarioEPlanner (Team-First planning mode).
//
// Team-First mode allows users to select multiple teams and finds optimal
// trip windows across the season where all selected teams have home games.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ScenarioEPlanner")
struct ScenarioEPlannerTests {
// MARK: - Test Data
private let planner = ScenarioEPlanner(currentDate: TestClock.now)
// East Coast coordinates
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
// Central coordinates
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
// West Coast coordinates
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
// MARK: - E1. Window Generator Tests
@Test("generateValidWindows: 2 teams with 4-day window finds valid windows")
func generateValidWindows_2Teams4DayWindow_findsValidWindows() {
// Setup: 2 teams (simpler case), each with home games in overlapping time periods
// NYC and Boston are only ~4 hours apart, making routes feasible
//
// With 2 teams, window duration = 4 days.
// The window algorithm checks: windowEnd <= latestGameDay + 1
// So with games on day 1 and day 4: latestDay=4, windowEnd=5 <= day 5 (4+1) - valid!
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create games with evening times (7 PM) to give ample travel time
// Day 1 evening: Yankees home game
// Day 4 evening: Red Sox home game (spans 4 days, fits in 4-day window)
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day4Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
// Should succeed - NYC to Boston is easily drivable
guard case .success(let options) = result else {
Issue.record("Expected success with overlapping home games for nearby cities")
return
}
#expect(!options.isEmpty, "Should find valid windows when all teams have overlapping home games")
}
@Test("generateValidWindows: window with only 2 of 3 teams excluded")
func generateValidWindows_windowMissingTeam_excluded() {
// Setup: 3 teams selected, but games are spread so no single window covers all 3
let baseDate = 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)
// Yankees game on day 1
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
)
// Red Sox game on day 2
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 2)
)
// Phillies game on day 20 (way outside any 6-day window with the others)
let philliesGame = makeGame(
id: "phillies-home",
homeTeamId: "phillies",
awayTeamId: "opponent",
stadiumId: "philly",
dateTime: baseDate.addingTimeInterval(86400 * 20)
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox", "phillies"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame, philliesGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox"),
"phillies": makeTeam(id: "phillies", name: "Phillies")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no valid window covers all teams")
return
}
#expect(failure.reason == .noValidRoutes, "Should return noValidRoutes when no window covers all teams")
}
@Test("generateValidWindows: empty season returns empty")
func generateValidWindows_emptySeason_returnsEmpty() {
// Setup: No games available
let baseDate = TestClock.now
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [], // No games
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: [:]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure with no games")
return
}
#expect(failure.reason == .noGamesInRange, "Should return noGamesInRange when no home games exist")
}
@Test("generateValidWindows: sampling works when more than 50 windows")
func generateValidWindows_manyWindows_samplesProperly() {
// Setup: Create many overlapping games so there are >50 valid windows
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create games on alternating days - Yankees on even days, Red Sox on odd days
// This ensures games don't conflict and routes are feasible
// With 90 days of games, we get ~45 games per team with plenty of valid 4-day windows
var games: [Game] = []
for day in 0..<90 {
let dayDate = baseDate.addingTimeInterval(Double(86400 * day))
let eveningTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: dayDate)!
if day % 2 == 0 {
// Yankees home games on even days
let yankeesGame = makeGame(
id: "yankees-\(day)",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: eveningTime
)
games.append(yankeesGame)
} else {
// Red Sox home games on odd days
let redsoxGame = makeGame(
id: "redsox-\(day)",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: eveningTime
)
games.append(redsoxGame)
}
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 90),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
// Should succeed and return results (sampling internal behavior)
guard case .success(let options) = result else {
Issue.record("Expected success with many valid windows")
return
}
#expect(!options.isEmpty, "Should return options even with many windows (sampling applies internally)")
#expect(options.count <= 10, "Should return at most 10 results")
}
// MARK: - E2. ScenarioEPlanner Unit Tests
@Test("plan: returns PlanningResult with routes")
func plan_validRequest_returnsPlanningResultWithRoutes() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Use evening game times to allow travel during the day
// Games must span 4 days for 2-team window (day 1 to day 4)
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day4Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with valid team-first request")
return
}
#expect(!options.isEmpty, "Should return route options")
#expect(options.first?.stops.count ?? 0 > 0, "Each option should have stops")
}
@Test("plan: all routes include all selected teams")
func plan_allRoutesIncludeAllSelectedTeams() {
// Use just 2 teams for a simpler, more reliable test
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Games within a 4-day window (2 teams * 2) with evening times
// Games must span 4 days for the window to be valid
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day4Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with valid team-first request")
return
}
// Verify each route includes games from all 2 teams
for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games })
// Check that at least one home game per team is included
let hasYankeesGame = allGameIds.contains("yankees-home")
let hasRedsoxGame = allGameIds.contains("redsox-home")
#expect(hasYankeesGame, "Every route must include Yankees home game")
#expect(hasRedsoxGame, "Every route must include Red Sox home game")
}
}
@Test("plan: falls back when earliest per-team anchors are infeasible")
func plan_fallbackWhenEarliestAnchorsInfeasible() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord)
// Team A has one early game in NYC.
let teamAGame = makeGame(
id: "team-a-day1",
homeTeamId: "teamA",
awayTeamId: "opp",
stadiumId: "nyc",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
)
// Team B has an early game (day 2, infeasible from NYC with 1 driver),
// and a later game (day 4, feasible and should be selected by fallback).
let teamBEarly = makeGame(
id: "team-b-day2",
homeTeamId: "teamB",
awayTeamId: "opp",
stadiumId: "chi",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 2))!
)
let teamBLate = makeGame(
id: "team-b-day4",
homeTeamId: "teamB",
awayTeamId: "opp",
stadiumId: "chi",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
selectedTeamIds: ["teamA", "teamB"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [teamAGame, teamBEarly, teamBLate],
teams: [
"teamA": makeTeam(id: "teamA", name: "Team A"),
"teamB": makeTeam(id: "teamB", name: "Team B")
],
stadiums: ["nyc": nycStadium, "chi": chicagoStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected fallback success when earliest anchor combo is infeasible")
return
}
#expect(!options.isEmpty)
let optionGameIds = options.map { Set($0.stops.flatMap { $0.games }) }
#expect(optionGameIds.contains { $0.contains("team-a-day1") && $0.contains("team-b-day4") },
"Expected at least one route that uses the later feasible Team B game")
}
@Test("plan: keeps date-distinct options even when city order is identical")
func plan_keepsDistinctGameSetsWithSameCityOrder() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let teamAFirst = makeGame(
id: "team-a-day1",
homeTeamId: "teamA",
awayTeamId: "opp",
stadiumId: "nyc",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
)
let teamBFirst = makeGame(
id: "team-b-day4",
homeTeamId: "teamB",
awayTeamId: "opp",
stadiumId: "boston",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
)
let teamASecond = makeGame(
id: "team-a-day10",
homeTeamId: "teamA",
awayTeamId: "opp",
stadiumId: "nyc",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))!
)
let teamBSecond = makeGame(
id: "team-b-day13",
homeTeamId: "teamB",
awayTeamId: "opp",
stadiumId: "boston",
dateTime: calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))!
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["teamA", "teamB"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [teamAFirst, teamBFirst, teamASecond, teamBSecond],
teams: [
"teamA": makeTeam(id: "teamA", name: "Team A"),
"teamB": makeTeam(id: "teamB", name: "Team B")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success for repeated city-order windows")
return
}
let uniqueGameSets = Set(options.map { option in
option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
})
#expect(uniqueGameSets.count >= 2, "Expected distinct date/game combinations to survive deduplication")
}
@Test("plan: routes sorted by duration ascending")
func plan_routesSortedByDurationAscending() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
// Window 1: Games on day 1 and day 4 (tighter)
// Window 2: Games on day 10 and day 13 (separate window)
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let day10Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))!
let day13Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))!
let yankeesGame1 = makeGame(
id: "yankees-1",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let redsoxGame1 = makeGame(
id: "redsox-1",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day4Evening
)
let yankeesGame2 = makeGame(
id: "yankees-2",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day10Evening
)
let redsoxGame2 = makeGame(
id: "redsox-2",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day13Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame1, redsoxGame1, yankeesGame2, redsoxGame2],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success")
return
}
// Verify ranking is assigned correctly
for (index, option) in options.enumerated() {
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...")
}
// Verify actual duration ordering: each option's trip duration <= next option's
for i in 0..<(options.count - 1) {
let daysA = Calendar.current.dateComponents([.day], from: options[i].stops.first!.arrivalDate, to: options[i].stops.last!.departureDate).day ?? 0
let daysB = Calendar.current.dateComponents([.day], from: options[i+1].stops.first!.arrivalDate, to: options[i+1].stops.last!.departureDate).day ?? 0
#expect(daysA <= daysB, "Option \(i) duration \(daysA)d should be <= option \(i+1) duration \(daysB)d")
}
}
@Test("plan: respects max driving time constraint")
func plan_respectsMaxDrivingTimeConstraint() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
// NYC and LA are ~40 hours apart by car
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
// Spread games apart so the window generator produces a valid window,
// but keep them on opposite coasts so the driving constraint rejects the route.
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day5Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 5))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let dodgersGame = makeGame(
id: "dodgers-home",
homeTeamId: "dodgers",
awayTeamId: "opponent",
stadiumId: "la",
dateTime: day5Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1, // Single driver, 8 hours max impossible NYCLA
selectedTeamIds: ["yankees", "dodgers"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, dodgersGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"dodgers": makeTeam(id: "dodgers", name: "Dodgers")
],
stadiums: ["nyc": nycStadium, "la": laStadium]
)
let result = planner.plan(request: request)
// Should fail because driving constraint cannot be met
guard case .failure(let failure) = result else {
Issue.record("Expected failure when driving constraint cannot be met")
return
}
// Could be noValidRoutes or constraintsUnsatisfiable
let validFailures: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable]
#expect(validFailures.contains { $0 == failure.reason }, "Should fail due to route constraints")
}
// MARK: - E4. Edge Case Tests
@Test("plan: teams with no overlapping games returns graceful error")
func plan_noOverlappingGames_returnsGracefulError() {
let baseDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Yankees plays in January, Red Sox plays in July - no overlap
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate // Day 0
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 180) // 6 months later
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 365),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when teams have no overlapping game windows")
return
}
#expect(failure.reason == .noValidRoutes, "Should return noValidRoutes for non-overlapping schedules")
}
@Test("plan: single team selected returns validation error")
func plan_singleTeamSelected_returnsValidationError() {
let baseDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees"] // Only 1 team
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees")],
stadiums: ["nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when only 1 team selected")
return
}
#expect(failure.reason == .missingTeamSelection, "Should return missingTeamSelection for single team")
}
@Test("plan: no teams selected returns validation error")
func plan_noTeamsSelected_returnsValidationError() {
let baseDate = TestClock.now
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: [] // No teams
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no teams selected")
return
}
#expect(failure.reason == .missingTeamSelection, "Should return missingTeamSelection for no teams")
}
@Test("plan: teams in same city treated as separate stops")
func plan_teamsInSameCity_treatedAsSeparateStops() {
// Setup: Yankees and Mets both play in NYC but at different stadiums
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)
let citiFieldCoord = CLLocationCoordinate2D(latitude: 40.7571, longitude: -73.8458)
let yankeeStadium = makeStadium(id: "yankee-stadium", city: "New York", coordinate: yankeeStadiumCoord)
let citiField = makeStadium(id: "citi-field", city: "New York", coordinate: citiFieldCoord)
// Games on different days in the same city with evening times
// Games must span 4 days for the 2-team window to be valid
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "yankee-stadium",
dateTime: day1Evening
)
let metsGame = makeGame(
id: "mets-home",
homeTeamId: "mets",
awayTeamId: "opponent",
stadiumId: "citi-field",
dateTime: day4Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "mets"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, metsGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"mets": makeTeam(id: "mets", name: "Mets")
],
stadiums: ["yankee-stadium": yankeeStadium, "citi-field": citiField]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with same-city teams")
return
}
#expect(!options.isEmpty, "Should find routes for same-city teams")
// Verify both games are included in routes
for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games })
#expect(allGameIds.contains("yankees-home"), "Should include Yankees game")
#expect(allGameIds.contains("mets-home"), "Should include Mets game")
}
}
@Test("plan: team with no home games returns error")
func plan_teamWithNoHomeGames_returnsError() {
let baseDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
// Only Yankees have a home game, Red Sox have no home games
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "redsox", // Red Sox are away
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"] // Red Sox selected but no home games
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when selected team has no home games")
return
}
#expect(failure.reason == .noGamesInRange, "Should return noGamesInRange when team has no home games")
}
// MARK: - Invariant Tests
@Test("Invariant: window duration equals teams count times 2")
func invariant_windowDurationEqualsTeamsTimestwo() {
// Test that teamFirstMaxDays is calculated correctly
var prefs = TripPreferences()
prefs.selectedTeamIds = ["team1", "team2", "team3"]
#expect(prefs.teamFirstMaxDays == 6, "3 teams should result in 6-day window")
prefs.selectedTeamIds = ["team1", "team2"]
#expect(prefs.teamFirstMaxDays == 4, "2 teams should result in 4-day window")
prefs.selectedTeamIds = ["team1", "team2", "team3", "team4", "team5"]
#expect(prefs.teamFirstMaxDays == 10, "5 teams should result in 10-day window")
}
@Test("Invariant: maximum 10 results returned")
func invariant_maximum10ResultsReturned() {
let baseDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create many games to generate many possible routes
var games: [Game] = []
for day in 0..<60 {
games.append(makeGame(
id: "yankees-\(day)",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(Double(86400 * day))
))
games.append(makeGame(
id: "redsox-\(day)",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(Double(86400 * day))
))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 60),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(options.count <= 10, "Should return at most 10 results")
}
@Test("Invariant: all routes contain home games from all selected teams")
func invariant_allRoutesContainAllSelectedTeams() {
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: day1Evening
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: day4Evening
)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [yankeesGame, redsoxGame],
teams: [
"yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")
],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games })
// At minimum, should have one game per selected team
let hasYankeesGame = allGameIds.contains { gameId in
// Check if any game in this route is a Yankees home game
request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees"
}
let hasRedsoxGame = allGameIds.contains { gameId in
request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox"
}
#expect(hasYankeesGame, "Every route must include a Yankees home game")
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
}
}
// MARK: - Region Filter Tests
@Test("teamFirst: east region only excludes west games")
func teamFirst_eastRegionOnly_excludesWestGames() {
// Create two teams: one east (NYC), one also east (Boston)
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos")
// LA game should be excluded by east-only filter
let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day3, homeTeamId: "team_nyc", stadiumId: "stadium_la")
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedRegions: [.east], // East only
selectedTeamIds: ["team_nyc", "team_bos"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gameBOS, gameLA],
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS, "stadium_la": stadiumLA]
)
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// Should succeed both teams have east coast games.
// Failure is also acceptable if routing constraints prevent a valid route.
switch result {
case .success(let options):
for option in options {
let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA")
}
case .failure:
break // Acceptable routing constraints may prevent a valid route
}
}
@Test("teamFirst: all regions includes everything")
func teamFirst_allRegions_includesEverything() {
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
// 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [.east, .central, .west], // All regions
selectedTeamIds: ["team_nyc", "team_bos"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gameBOS],
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
)
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// With all regions and nearby east-coast cities, planning should succeed
guard case .success(let options) = result else {
Issue.record("Expected .success with all regions and feasible route, got \(result)")
return
}
#expect(!options.isEmpty, "Should return at least one route option")
}
@Test("teamFirst: empty regions includes everything")
func teamFirst_emptyRegions_includesEverything() {
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
// 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [], // Empty = no filtering
selectedTeamIds: ["team_nyc", "team_bos"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gameBOS],
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
)
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// Empty regions = no filtering, so both games should be available and route feasible
guard case .success(let options) = result else {
Issue.record("Expected .success with empty regions (no filtering) and feasible route, got \(result)")
return
}
#expect(!options.isEmpty, "Should return at least one route option")
}
// MARK: - Past Date Filtering Tests
@Test("teamFirst: past-only games return noValidRoutes")
func teamFirst_pastOnlyGames_returnsNoResults() {
// Simulate: currentDate is June 1, but all games are in March (past)
let calendar = TestClock.calendar
let currentDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12)
let pastDate = TestFixtures.date(year: 2026, month: 3, day: 10, hour: 19)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let pastGame1 = makeGame(id: "past-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc", dateTime: pastDate)
let pastGame2 = makeGame(id: "past-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
dateTime: calendar.date(byAdding: .day, value: 2, to: pastDate)!)
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastGame1, pastGame2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
// All games are in the past no valid windows should exist
guard case .failure(let failure) = result else {
Issue.record("Expected failure when all games are in the past")
return
}
#expect(failure.reason == .noValidRoutes, "Should fail with noValidRoutes when all windows are in the past")
}
@Test("teamFirst: mix of past and future games only returns future windows")
func teamFirst_mixPastFuture_onlyReturnsFutureWindows() {
let calendar = TestClock.calendar
// Current date: March 15, 2026
let currentDate = TestFixtures.date(year: 2026, month: 3, day: 15, hour: 12)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Past games (early March)
let pastGame1 = makeGame(id: "past-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 2, hour: 19))
let pastGame2 = makeGame(id: "past-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 3, hour: 19))
// Future games (late March / April)
let futureGame1 = makeGame(id: "future-yankees", homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 20, hour: 19))
let futureGame2 = makeGame(id: "future-redsox", homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 22, hour: 19))
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastGame1, pastGame2, futureGame1, futureGame2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
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
}
// All returned stops should be on or after currentDate
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
"All stops should be in the future, got \(stop.arrivalDate)")
}
}
}
@Test("teamFirst: evaluates all sampled windows across full season")
func teamFirst_evaluatesAllSampledWindows_fullSeasonCoverage() {
let calendar = TestClock.calendar
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create games spread across April-September (6 months)
// Each team gets a home game every ~10 days
var allGames: [Game] = []
for monthOffset in 0..<6 {
let month = 4 + monthOffset
for dayOffset in stride(from: 1, through: 25, by: 10) {
let gameDate = TestFixtures.date(year: 2026, month: month, day: dayOffset, hour: 19)
allGames.append(makeGame(
id: "yankees-\(month)-\(dayOffset)",
homeTeamId: "yankees", awayTeamId: "opp", stadiumId: "nyc",
dateTime: gameDate
))
let gameDate2 = calendar.date(byAdding: .day, value: 1, to: gameDate)!
allGames.append(makeGame(
id: "redsox-\(month)-\(dayOffset)",
homeTeamId: "redsox", awayTeamId: "opp", stadiumId: "boston",
dateTime: gameDate2
))
}
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: allGames,
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
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 with games spread across full season")
return
}
#expect(!options.isEmpty, "Should find options across the season")
// Verify results span multiple months (not clustered in first month)
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: \(months.sorted())")
}
// MARK: - Output Sanity
@Test("plan: all stop dates in the future (synthetic regression)")
func plan_allStopDatesInFuture_syntheticRegression() {
// Regression for the PHI/WSN/BAL bug: past spring training games in output
let currentDate = TestFixtures.date(year: 2026, month: 6, 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)
// Mix of past and future games
let pastGame1 = makeGame(id: "past-nyc", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 10, hour: 13))
let pastGame2 = makeGame(id: "past-bos", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 12, hour: 13))
let futureGame1 = makeGame(id: "future-nyc", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 6, day: 5, hour: 19))
let futureGame2 = makeGame(id: "future-bos", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 6, day: 7, hour: 19))
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastGame1, pastGame2, futureGame1, futureGame2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
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 startOfDay = calendar.startOfDay(for: currentDate)
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= startOfDay,
"Stop on \(stop.arrivalDate) is before currentDate \(startOfDay)")
}
}
}
@Test("plan: results cover multiple months when games spread across season")
func plan_resultsCoverMultipleMonths() {
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)
var games: [Game] = []
for month in 4...9 {
let dt1 = TestFixtures.date(year: 2026, month: month, day: 5, hour: 19)
let dt2 = TestFixtures.date(year: 2026, month: month, day: 7, hour: 19)
games.append(makeGame(id: "nyc-\(month)", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: dt1))
games.append(makeGame(id: "bos-\(month)", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston", dateTime: dt2))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
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
}
#expect(options.count >= 2, "Should have multiple options across season")
let months = Set(options.flatMap { opt in
opt.stops.map { calendar.component(.month, from: $0.arrivalDate) }
})
#expect(months.count >= 2,
"Results should span multiple months, got: \(months.sorted())")
}
@Test("plan: every option has all selected teams")
func plan_everyOptionHasAllSelectedTeams_tighter() {
let currentDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
var games: [Game] = []
for day in stride(from: 1, through: 30, by: 3) {
games.append(makeGame(id: "nyc-\(day)", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestClock.addingDays(day)))
games.append(makeGame(id: "bos-\(day)", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestClock.addingDays(day + 1)))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f })
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
}
for (idx, option) in options.enumerated() {
let homeTeams = Set(
option.stops.flatMap { $0.games }
.compactMap { gameMap[$0]?.homeTeamId }
)
#expect(homeTeams.contains("yankees"),
"Option \(idx): missing Yankees home game")
#expect(homeTeams.contains("redsox"),
"Option \(idx): missing Red Sox home game")
}
}
// MARK: - Helper Methods
private func makeStadium(
id: String,
city: String,
coordinate: CLLocationCoordinate2D
) -> Stadium {
Stadium(
id: id,
name: "\(city) Stadium",
city: city,
state: "XX",
latitude: coordinate.latitude,
longitude: coordinate.longitude,
capacity: 40000,
sport: .mlb
)
}
private func makeGame(
id: String,
homeTeamId: String,
awayTeamId: String,
stadiumId: String,
dateTime: Date
) -> Game {
Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
}
private func makeTeam(id: String, name: String) -> Team {
Team(
id: id,
name: name,
abbreviation: String(name.prefix(3)).uppercased(),
sport: .mlb,
city: "Test City",
stadiumId: id,
primaryColor: "#000000",
secondaryColor: "#FFFFFF"
)
}
}