// // 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 NYC→LA 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 (NYC→LA is ~40h, single driver max 8h/day) guard case .failure(let failure) = result else { Issue.record("Expected failure when driving constraint cannot be met (NYC→LA with 1 driver, 8h max)") return } // Verify the failure is specifically about driving/route constraints, not a generic error let validFailures: [PlanningFailure.FailureReason] = [.noValidRoutes, .constraintsUnsatisfiable, .drivingExceedsLimit] #expect(validFailures.contains { $0 == failure.reason }, "Expected driving-related failure (.noValidRoutes, .constraintsUnsatisfiable, or .drivingExceedsLimit), got \(failure.reason)") } // 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" ) } }