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