diff --git a/SportsTime/Planning/Engine/ScenarioEPlanner.swift b/SportsTime/Planning/Engine/ScenarioEPlanner.swift index 3f284fd..f0d088d 100644 --- a/SportsTime/Planning/Engine/ScenarioEPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioEPlanner.swift @@ -59,6 +59,13 @@ final class ScenarioEPlanner: ScenarioPlanner { /// Maximum number of results to return private let maxResultsToReturn = 10 + /// Current date used to filter out past windows. Injectable for testing. + private let currentDate: Date + + init(currentDate: Date = Date()) { + self.currentDate = currentDate + } + // MARK: - ScenarioPlanner Protocol func plan(request: PlanningRequest) -> ItineraryResult { @@ -181,7 +188,7 @@ final class ScenarioEPlanner: ScenarioPlanner { // ────────────────────────────────────────────────────────────────── var allItineraryOptions: [ItineraryOption] = [] - for (windowIndex, window) in windowsToEvaluate.enumerated() { + for window in windowsToEvaluate { // Collect games in this window var gamesByTeamInWindow: [String: [Game]] = [:] var hasAllTeamsInWindow = true @@ -296,13 +303,10 @@ final class ScenarioEPlanner: ScenarioPlanner { allItineraryOptions.append(option) } - // Early exit if we have enough options - if allItineraryOptions.count >= maxResultsToReturn * 5 { - #if DEBUG - print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options") - #endif - break - } + // No early exit — evaluate all sampled windows so results + // span the full season instead of clustering around early dates. + // The 50-window sample cap + final dedup + top-10 ranking are + // sufficient throttles (beam search on ~10 games/window is fast). } // ────────────────────────────────────────────────────────────────── @@ -384,9 +388,12 @@ final class ScenarioEPlanner: ScenarioPlanner { let earliestDay = calendar.startOfDay(for: earliest) let latestDay = calendar.startOfDay(for: latest) + // Skip past windows — users only want future trips + let today = calendar.startOfDay(for: currentDate) + // Generate sliding windows var validWindows: [DateInterval] = [] - var currentStart = earliestDay + var currentStart = max(earliestDay, today) while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart), windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! { diff --git a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift index 98fbdac..f0efb9a 100644 --- a/SportsTimeTests/Planning/ScenarioEPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioEPlannerTests.swift @@ -17,7 +17,7 @@ struct ScenarioEPlannerTests { // MARK: - Test Data - private let planner = ScenarioEPlanner() + private let planner = ScenarioEPlanner(currentDate: TestClock.now) // East Coast coordinates private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) @@ -1177,6 +1177,157 @@ struct ScenarioEPlannerTests { #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( diff --git a/SportsTimeTests/Planning/TeamFirstIntegrationTests.swift b/SportsTimeTests/Planning/TeamFirstIntegrationTests.swift index 97c3713..4fa1a66 100644 --- a/SportsTimeTests/Planning/TeamFirstIntegrationTests.swift +++ b/SportsTimeTests/Planning/TeamFirstIntegrationTests.swift @@ -17,7 +17,7 @@ struct TeamFirstIntegrationTests { // MARK: - Test Data - private let planner = ScenarioEPlanner() + private let planner = ScenarioEPlanner(currentDate: TestClock.now) // MLB stadiums with realistic coordinates private let yankeeStadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262)