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:
51
SportsTimeTests/Domain/TravelInfoTests.swift
Normal file
51
SportsTimeTests/Domain/TravelInfoTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user