feat: improve planning engine travel handling, itinerary reordering, and scenario planners

Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 08:55:23 -06:00
parent 1c97f35754
commit 9736773475
19 changed files with 928 additions and 171 deletions

View File

@@ -406,6 +406,146 @@ struct ScenarioEPlannerTests {
}
}
@Test("plan: falls back when earliest per-team anchors are infeasible")
func plan_fallbackWhenEarliestAnchorsInfeasible() {
let calendar = Calendar.current
let baseDate = calendar.startOfDay(for: Date())
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 = Calendar.current
let baseDate = calendar.startOfDay(for: Date())
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 = Date()