Fix team-first future window selection
This commit is contained in:
@@ -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)! {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user