1382 lines
54 KiB
Swift
1382 lines
54 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 baseDate = TestClock.now
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
|
|
// Create multiple windows with different durations
|
|
// Window 1: Games on day 1 and 2 (shorter trip)
|
|
// Window 2: Games on day 10 and 14 (longer trip within window)
|
|
let yankeesGame1 = makeGame(
|
|
id: "yankees-1",
|
|
homeTeamId: "yankees",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "nyc",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
|
)
|
|
let redsoxGame1 = makeGame(
|
|
id: "redsox-1",
|
|
homeTeamId: "redsox",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "boston",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
|
)
|
|
let yankeesGame2 = makeGame(
|
|
id: "yankees-2",
|
|
homeTeamId: "yankees",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "nyc",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 10)
|
|
)
|
|
let redsoxGame2 = makeGame(
|
|
id: "redsox-2",
|
|
homeTeamId: "redsox",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "boston",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 12)
|
|
)
|
|
|
|
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...")
|
|
}
|
|
}
|
|
|
|
@Test("plan: respects max driving time constraint")
|
|
func plan_respectsMaxDrivingTimeConstraint() {
|
|
let baseDate = 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)
|
|
|
|
// Games on consecutive days - impossible to drive between
|
|
let yankeesGame = makeGame(
|
|
id: "yankees-home",
|
|
homeTeamId: "yankees",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "nyc",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
|
)
|
|
let dodgersGame = makeGame(
|
|
id: "dodgers-home",
|
|
homeTeamId: "dodgers",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "la",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible
|
|
)
|
|
|
|
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
|
|
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)
|
|
|
|
if case .success(let options) = result {
|
|
#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 baseDate = TestClock.now
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
|
|
let yankeesGame = makeGame(
|
|
id: "yankees-home",
|
|
homeTeamId: "yankees",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "nyc",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 1)
|
|
)
|
|
let redsoxGame = makeGame(
|
|
id: "redsox-home",
|
|
homeTeamId: "redsox",
|
|
awayTeamId: "opponent",
|
|
stadiumId: "boston",
|
|
dateTime: baseDate.addingTimeInterval(86400 * 2)
|
|
)
|
|
|
|
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)
|
|
|
|
if case .success(let options) = result {
|
|
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
|
|
if case .success(let options) = result {
|
|
for option in options {
|
|
let cities = option.stops.map { $0.city }
|
|
#expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA")
|
|
}
|
|
}
|
|
// If it fails, that's also acceptable since routing may not work out
|
|
}
|
|
|
|
@Test("teamFirst: all regions includes everything")
|
|
func teamFirst_allRegions_includesEverything() {
|
|
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
|
|
let teamLA = TestFixtures.team(id: "team_la", city: "Los Angeles")
|
|
|
|
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
|
|
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 day5 = TestClock.calendar.date(byAdding: .day, value: 4, to: baseDate)!
|
|
|
|
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
|
|
let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day5, homeTeamId: "team_la", stadiumId: "stadium_la")
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .teamFirst,
|
|
sports: [.mlb],
|
|
selectedRegions: [.east, .central, .west], // All regions
|
|
selectedTeamIds: ["team_nyc", "team_la"]
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [gameNYC, gameLA],
|
|
teams: ["team_nyc": teamNYC, "team_la": teamLA],
|
|
stadiums: ["stadium_nyc": stadiumNYC, "stadium_la": stadiumLA]
|
|
)
|
|
|
|
let planner = ScenarioEPlanner()
|
|
let result = planner.plan(request: request)
|
|
|
|
// With all regions, both games should be available
|
|
// (may still fail due to driving constraints, but games won't be region-filtered)
|
|
#expect(result.isSuccess || result.failure?.reason == .constraintsUnsatisfiable || result.failure?.reason == .noValidRoutes)
|
|
}
|
|
|
|
@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)
|
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, 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")
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .teamFirst,
|
|
sports: [.mlb],
|
|
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
|
|
#expect(result.isSuccess || result.failure?.reason != .noGamesInRange)
|
|
}
|
|
|
|
// 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)
|
|
|
|
if case .success(let options) = result {
|
|
// 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)")
|
|
}
|
|
}
|
|
}
|
|
// Failure is acceptable if routing constraints prevent a valid route
|
|
}
|
|
|
|
@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: - 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"
|
|
)
|
|
}
|
|
}
|