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

@@ -0,0 +1,51 @@
//
// TravelInfoTests.swift
// SportsTimeTests
//
// Tests for canonical TravelInfo construction and city matching.
//
import Testing
@testable import SportsTime
@Suite("TravelInfo")
struct TravelInfoTests {
private func makeSegment(from: String, to: String) -> TravelSegment {
TravelSegment(
fromLocation: LocationInput(name: from),
toLocation: LocationInput(name: to),
travelMode: .drive,
distanceMeters: 120_000,
durationSeconds: 7_200
)
}
@Test("init(segment:) derives canonical city pair and metrics")
func initFromSegment() {
let segment = makeSegment(from: "Detroit", to: "Chicago")
let info = TravelInfo(segment: segment, segmentIndex: 2)
#expect(info.fromCity == "Detroit")
#expect(info.toCity == "Chicago")
#expect(info.segmentIndex == 2)
#expect(info.distanceMeters == segment.distanceMeters)
#expect(info.durationSeconds == segment.durationSeconds)
}
@Test("normalizeCityName trims and lowercases")
func normalizeCityName() {
#expect(TravelInfo.normalizeCityName(" New York ") == "new york")
}
@Test("matches(segment:) uses normalized city comparison")
func matchesSegment() {
let segment = makeSegment(from: "Seattle", to: "Portland")
let info = TravelInfo(fromCity: " seattle ", toCity: "PORTLAND ")
#expect(info.matches(segment: segment))
#expect(info.matches(from: "SEATTLE", to: "portland"))
#expect(!info.matches(from: "Seattle", to: "San Francisco"))
}
}

View File

@@ -353,6 +353,36 @@ struct GameDAGRouterTests {
#expect(!combinedRoutes.isEmpty, "NYC to Chicago over 2 days should be feasible")
}
@Test("findRoutes: anchor routes can span gaps larger than 5 days")
func findRoutes_anchorRoutesAllowLongDateGaps() {
let today = calendar.startOfDay(for: Date())
let day0 = today
let day1 = calendar.date(byAdding: .day, value: 1, to: today)!
let day8 = calendar.date(byAdding: .day, value: 8, to: today)!
let sharedStadium = makeStadium(city: "New York", coord: nycCoord)
let bridgeStadium = makeStadium(city: "Boston", coord: bostonCoord)
let anchorStart = makeGame(stadiumId: sharedStadium.id, date: day0)
let bridgeGame = makeGame(stadiumId: bridgeStadium.id, date: day1)
let anchorEnd = makeGame(stadiumId: sharedStadium.id, date: day8)
let routes = GameDAGRouter.findRoutes(
games: [anchorStart, bridgeGame, anchorEnd],
stadiums: [sharedStadium.id: sharedStadium, bridgeStadium.id: bridgeStadium],
constraints: constraints,
anchorGameIds: [anchorStart.id, anchorEnd.id]
)
#expect(!routes.isEmpty, "Expected a route that includes both anchors across an 8-day gap")
for route in routes {
let ids = Set(route.map { $0.id })
#expect(ids.contains(anchorStart.id), "Route should include start anchor")
#expect(ids.contains(anchorEnd.id), "Route should include end anchor")
}
}
// MARK: - Property Tests
@Test("Property: route count never exceeds maxOptions (75)")

View File

@@ -220,6 +220,55 @@ struct ScenarioAPlannerTests {
#expect(failure.reason == .noGamesInRange)
}
@Test("plan: multiple must-stop cities are required without excluding other route games")
func plan_multipleMustStops_requireCoverageWithoutExclusiveFiltering() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 10)
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: CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 1))
let bostonGame = makeGame(id: "boston-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3))
let phillyGame = makeGame(id: "philly-game", stadiumId: "philly", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
mustStopLocations: [
LocationInput(name: "New York", coordinate: nycCoord),
LocationInput(name: "Boston", coordinate: bostonCoord)
],
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [nycGame, bostonGame, phillyGame],
teams: [:],
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with two feasible must-stop cities")
return
}
#expect(!options.isEmpty)
for option in options {
let gameIds = Set(option.stops.flatMap { $0.games })
#expect(gameIds.contains("nyc-game"), "Each option should satisfy New York must-stop")
#expect(gameIds.contains("boston-game"), "Each option should satisfy Boston must-stop")
}
}
// MARK: - Specification Tests: Successful Planning
@Test("plan: single game in range returns success with one option")

View File

@@ -297,6 +297,49 @@ struct ScenarioBPlannerTests {
// May also fail if no valid date ranges, which is acceptable
}
@Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
func plan_explicitDateRange_selectedGameOutsideRange_returnsDateRangeViolation() {
let baseDate = Date()
let rangeStart = baseDate
let rangeEnd = baseDate.addingTimeInterval(86400 * 3)
let outOfRangeDate = baseDate.addingTimeInterval(86400 * 10)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
let selectedGame = makeGame(id: "outside-anchor", stadiumId: "stadium1", dateTime: outOfRangeDate)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
mustSeeGameIds: ["outside-anchor"],
startDate: rangeStart,
endDate: rangeEnd,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [selectedGame],
teams: [:],
stadiums: ["stadium1": stadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected date range violation when selected game is outside explicit range")
return
}
guard case .dateRangeViolation(let violatingGames) = failure.reason else {
Issue.record("Expected .dateRangeViolation, got \(failure.reason)")
return
}
#expect(Set(violatingGames.map { $0.id }) == ["outside-anchor"])
}
// MARK: - Specification Tests: Arrival Time Validation
@Test("plan: uses arrivalBeforeGameStart validator")

View File

@@ -197,6 +197,47 @@ struct ScenarioCPlannerTests {
#expect(failure.reason == .noGamesInRange)
}
@Test("plan: city names with state suffixes match stadium city names")
func plan_cityNamesWithStateSuffixes_matchStadiumCities() {
let baseDate = Date()
let endDate = baseDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: baseDate.addingTimeInterval(86400 * 1))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: baseDate.addingTimeInterval(86400 * 4))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: baseDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, nycGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .success = result else {
Issue.record("Expected success with city/state location labels matching plain stadium cities")
return
}
}
// MARK: - Specification Tests: Directional Filtering
@Test("plan: directional filtering includes stadiums toward destination")

View File

@@ -272,6 +272,63 @@ struct ScenarioDPlannerTests {
#expect(!options.isEmpty)
}
@Test("plan: useHomeLocation with startLocation adds home start and end stops")
func plan_useHomeLocationWithStartLocation_addsHomeEndpoints() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 10)
let homeCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) // Denver
let homeLocation = LocationInput(name: "Denver", coordinate: homeCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let game = Game(
id: "team-game",
homeTeamId: "red-sox",
awayTeamId: "yankees",
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 3),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
startLocation: homeLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
followTeamId: "yankees",
useHomeLocation: true
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
teams: [:],
stadiums: ["boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with home endpoint enabled")
return
}
#expect(!options.isEmpty)
for option in options {
#expect(option.stops.first?.city == "Denver")
#expect(option.stops.last?.city == "Denver")
#expect(option.stops.first?.games.isEmpty == true)
#expect(option.stops.last?.games.isEmpty == true)
}
}
// MARK: - Invariant Tests
@Test("Invariant: all returned games have team as home or away")

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()