Files
Sportstime/SportsTimeTests/Planning/TravelSegmentIntegrityTests.swift
Trey T 9b622f8bbb Harden planning test suite with realistic fixtures and output sanity checks
Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests,
and updates all scenario planner tests with improved coverage and assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:38:41 -05:00

798 lines
31 KiB
Swift

//
// TravelSegmentIntegrityTests.swift
// SportsTimeTests
//
// CRITICAL INVARIANT: Every itinerary option returned to users MUST have
// valid travel segments between ALL consecutive stops.
//
// N stops exactly N-1 travel segments. No exceptions.
//
// This file tests the invariant at every layer:
// 1. ItineraryBuilder.build() the segment factory
// 2. ItineraryOption.isValid the runtime check
// 3. TripPlanningEngine the final gate
// 4. Each scenario planner (A-E) end-to-end
// 5. Edge cases single stops, same-city, missing coords, cross-country
//
import Testing
import Foundation
import CoreLocation
@testable import SportsTime
// MARK: - Layer 1: ItineraryBuilder Invariant
@Suite("Travel Integrity: ItineraryBuilder")
struct TravelIntegrity_BuilderTests {
@Test("build: 2 stops → exactly 1 segment")
func build_twoStops_oneSegment() {
let nyc = TestFixtures.coordinates["New York"]!
let boston = TestFixtures.coordinates["Boston"]!
let stops = [
makeStop(city: "New York", coord: nyc, day: 0),
makeStop(city: "Boston", coord: boston, day: 1)
]
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil, "NYC→Boston should build")
#expect(result!.travelSegments.count == 1, "2 stops must have exactly 1 segment")
#expect(result!.travelSegments[0].estimatedDistanceMiles > 0, "Segment must have distance")
#expect(result!.travelSegments[0].estimatedDrivingHours > 0, "Segment must have duration")
}
@Test("build: 3 stops → exactly 2 segments")
func build_threeStops_twoSegments() {
let stops = [
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
makeStop(city: "Philadelphia", coord: TestFixtures.coordinates["Philadelphia"]!, day: 1),
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2)
]
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil)
#expect(result!.travelSegments.count == 2, "3 stops must have exactly 2 segments")
}
@Test("build: 5 stops → exactly 4 segments")
func build_fiveStops_fourSegments() {
let cities = ["New York", "Philadelphia", "Boston", "Chicago", "Detroit"]
let stops = cities.enumerated().map { i, city in
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
}
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil)
#expect(result!.travelSegments.count == 4, "5 stops must have exactly 4 segments")
}
@Test("build: single stop → 0 segments")
func build_singleStop_noSegments() {
let stops = [makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0)]
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil)
#expect(result!.travelSegments.isEmpty, "1 stop must have 0 segments")
}
@Test("build: empty stops → 0 segments")
func build_emptyStops_noSegments() {
let result = ItineraryBuilder.build(stops: [], constraints: .default)
#expect(result != nil)
#expect(result!.travelSegments.isEmpty)
#expect(result!.stops.isEmpty)
}
@Test("build: missing coordinates → returns nil (not partial)")
func build_missingCoords_returnsNil() {
let stops = [
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
makeStop(city: "Atlantis", coord: nil, day: 1), // No coords!
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2)
]
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result == nil, "Missing coordinates must reject entire itinerary, not produce partial")
}
@Test("build: infeasible segment → returns nil (not partial)")
func build_infeasibleSegment_returnsNil() {
// Use extremely tight constraints to make cross-country infeasible
let tightConstraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 1.0)
let stops = [
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 1)
]
// NYCLA is ~2,800 miles. With 1 hour/day max, exceeds 5x limit (5 hours)
let result = ItineraryBuilder.build(stops: stops, constraints: tightConstraints)
#expect(result == nil, "Infeasible segment must reject entire itinerary")
}
@Test("build: every segment connects the correct stops in order")
func build_segmentOrder_matchesStops() {
let cities = ["New York", "Philadelphia", "Boston"]
let stops = cities.enumerated().map { i, city in
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
}
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil)
// Verify segment endpoints match stop pairs
for i in 0..<result!.travelSegments.count {
let segment = result!.travelSegments[i]
let fromStop = result!.stops[i]
let toStop = result!.stops[i + 1]
#expect(segment.fromLocation.name == fromStop.city,
"Segment \(i) fromLocation must match stop \(i) city")
#expect(segment.toLocation.name == toStop.city,
"Segment \(i) toLocation must match stop \(i+1) city")
}
}
@Test("build: segment validator rejection → returns nil (not partial)")
func build_validatorRejection_returnsNil() {
let stops = [
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 0) // Same day
]
// Validator always rejects
let alwaysReject: ItineraryBuilder.SegmentValidator = { _, _, _ in false }
let result = ItineraryBuilder.build(
stops: stops, constraints: .default, segmentValidator: alwaysReject
)
#expect(result == nil, "Rejected validator must fail entire build")
}
}
// MARK: - Layer 2: ItineraryOption.isValid
@Suite("Travel Integrity: isValid Property")
struct TravelIntegrity_IsValidTests {
@Test("isValid: correct segment count → true")
func isValid_correct_true() {
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
let option = ItineraryOption(
rank: 1,
stops: [
makeStop(city: "New York", coord: nil, day: 0),
makeStop(city: "Boston", coord: nil, day: 1)
],
travelSegments: [segment],
totalDrivingHours: 3.5, totalDistanceMiles: 215,
geographicRationale: "test"
)
#expect(option.isValid == true)
}
@Test("isValid: too few segments → false")
func isValid_tooFew_false() {
let option = ItineraryOption(
rank: 1,
stops: [
makeStop(city: "New York", coord: nil, day: 0),
makeStop(city: "Boston", coord: nil, day: 1),
makeStop(city: "Chicago", coord: nil, day: 2)
],
travelSegments: [TestFixtures.travelSegment(from: "New York", to: "Boston")],
// Only 1 segment for 3 stops INVALID
totalDrivingHours: 3.5, totalDistanceMiles: 215,
geographicRationale: "test"
)
#expect(option.isValid == false, "3 stops with 1 segment must be invalid")
}
@Test("isValid: too many segments → false")
func isValid_tooMany_false() {
let option = ItineraryOption(
rank: 1,
stops: [
makeStop(city: "New York", coord: nil, day: 0),
makeStop(city: "Boston", coord: nil, day: 1)
],
travelSegments: [
TestFixtures.travelSegment(from: "New York", to: "Boston"),
TestFixtures.travelSegment(from: "Boston", to: "Chicago")
],
// 2 segments for 2 stops INVALID
totalDrivingHours: 10, totalDistanceMiles: 800,
geographicRationale: "test"
)
#expect(option.isValid == false, "2 stops with 2 segments must be invalid")
}
@Test("isValid: 0 stops with 0 segments → true")
func isValid_empty_true() {
let option = ItineraryOption(
rank: 1, stops: [], travelSegments: [],
totalDrivingHours: 0, totalDistanceMiles: 0,
geographicRationale: "empty"
)
#expect(option.isValid == true)
}
@Test("isValid: 1 stop with 0 segments → true")
func isValid_singleStop_true() {
let option = ItineraryOption(
rank: 1,
stops: [makeStop(city: "New York", coord: nil, day: 0)],
travelSegments: [],
totalDrivingHours: 0, totalDistanceMiles: 0,
geographicRationale: "single"
)
#expect(option.isValid == true)
}
@Test("isValid: 1 stop with 1 segment → false (orphan segment)")
func isValid_singleStopWithSegment_false() {
let option = ItineraryOption(
rank: 1,
stops: [makeStop(city: "New York", coord: nil, day: 0)],
travelSegments: [TestFixtures.travelSegment(from: "New York", to: "Boston")],
totalDrivingHours: 3.5, totalDistanceMiles: 215,
geographicRationale: "orphan segment"
)
#expect(option.isValid == false, "1 stop with segments must be invalid")
}
}
// MARK: - Layer 3: TripPlanningEngine Final Gate
@Suite("Travel Integrity: Engine Final Gate")
struct TravelIntegrity_EngineGateTests {
@Test("Engine never returns options where isValid is false")
func engine_neverReturnsInvalid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
// Generate realistic games across multiple days
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Atlanta"]
let games = cities.enumerated().map { i, city in
TestFixtures.game(
city: city,
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
)
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [:],
stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (i, option) in options.enumerated() {
#expect(option.isValid,
"Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID")
// Double-check the math
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
}
}
}
@Test("Engine rejects all-invalid options with segmentMismatch failure")
func engine_rejectsAllInvalid() {
// This tests the isValid filter in applyPreferenceFilters
// We can't easily inject invalid options, but we verify the code path exists
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
)
// No games should fail (not return empty success)
let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:])
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
#expect(!result.isSuccess, "No games should produce failure, not empty success")
}
}
// MARK: - Layer 4: End-to-End Scenario Tests
@Suite("Travel Integrity: Scenario A (Date Range)")
struct TravelIntegrity_ScenarioATests {
@Test("ScenarioA: all returned options have N-1 segments")
func scenarioA_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let games = ["New York", "Boston", "Philadelphia"].enumerated().map { i, city in
TestFixtures.game(
city: city,
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
)
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
)
let request = PlanningRequest(preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "A")
}
}
@Suite("Travel Integrity: Scenario B (Selected Games)")
struct TravelIntegrity_ScenarioBTests {
@Test("ScenarioB: all returned options have N-1 segments")
func scenarioB_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let game1 = TestFixtures.game(id: "must_see_1", city: "New York", dateTime: baseDate)
let game2 = TestFixtures.game(
id: "must_see_2", city: "Boston",
dateTime: TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
)
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
let prefs = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: ["must_see_1", "must_see_2"],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game1, game2],
teams: [:],
stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "B")
}
}
@Suite("Travel Integrity: Scenario C (Start/End Locations)")
struct TravelIntegrity_ScenarioCTests {
@Test("ScenarioC: all returned options have N-1 segments including endpoint stops")
func scenarioC_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let chicagoCoord = TestFixtures.coordinates["Chicago"]!
let nycCoord = TestFixtures.coordinates["New York"]!
// Games along the Chicago NYC route
let game1 = TestFixtures.game(
city: "Detroit",
dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
)
let game2 = TestFixtures.game(
city: "Philadelphia",
dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
)
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
let prefs = TripPreferences(
planningMode: .locations,
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game1, game2],
teams: [:],
stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "C")
}
}
@Suite("Travel Integrity: Scenario D (Follow Team)")
struct TravelIntegrity_ScenarioDTests {
@Test("ScenarioD: all returned options have N-1 segments")
func scenarioD_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let teamId = "team_mlb_new_york"
let game1 = TestFixtures.game(
city: "New York", dateTime: baseDate,
homeTeamId: teamId, stadiumId: "stadium_mlb_new_york"
)
let game2 = TestFixtures.game(
city: "Boston",
dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!,
homeTeamId: "team_mlb_boston",
awayTeamId: teamId,
stadiumId: "stadium_mlb_boston"
)
let stadiums = TestFixtures.stadiumMap(for: [game1, game2])
let team = TestFixtures.team(id: teamId, name: "Yankees", sport: .mlb, city: "New York")
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
followTeamId: teamId
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game1, game2],
teams: [teamId: team],
stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "D")
}
}
@Suite("Travel Integrity: Scenario E (Team-First)")
struct TravelIntegrity_ScenarioETests {
@Test("ScenarioE: all returned options have N-1 segments")
func scenarioE_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let nycTeam = TestFixtures.team(id: "team_nyc", name: "NYC Team", sport: .mlb, city: "New York")
let bosTeam = TestFixtures.team(id: "team_bos", name: "BOS Team", sport: .mlb, city: "Boston")
// Create home games for each team
let nycGames = (0..<3).map { i in
TestFixtures.game(
id: "nyc_\(i)", city: "New York",
dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2, to: baseDate)!,
homeTeamId: "team_nyc", stadiumId: "stadium_mlb_new_york"
)
}
let bosGames = (0..<3).map { i in
TestFixtures.game(
id: "bos_\(i)", city: "Boston",
dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2 + 1, to: baseDate)!,
homeTeamId: "team_bos", stadiumId: "stadium_mlb_boston"
)
}
let allGames = nycGames + bosGames
let stadiums = TestFixtures.stadiumMap(for: allGames)
let teams: [String: Team] = ["team_nyc": nycTeam, "team_bos": bosTeam]
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
selectedTeamIds: ["team_nyc", "team_bos"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: allGames,
teams: teams,
stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "E")
}
}
// MARK: - Layer 5: Edge Cases
@Suite("Travel Integrity: Edge Cases")
struct TravelIntegrity_EdgeCaseTests {
@Test("Same-city consecutive stops have zero-distance segment")
func sameCityStops_haveZeroDistanceSegment() {
let coord = TestFixtures.coordinates["New York"]!
let stops = [
makeStop(city: "New York", coord: coord, day: 0),
makeStop(city: "New York", coord: coord, day: 1)
]
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil, "Same-city stops should build")
#expect(result!.travelSegments.count == 1, "Must still have segment")
// Distance should be very small (same coords)
}
@Test("Cross-country trip (NYC→LA) rejected with 1 driver, feasible with 2")
func crossCountry_feasibilityDependsOnDrivers() {
let stops = [
makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0),
makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 5)
]
// 1 driver, 8 hrs/day max 40 hrs (5x limit). NYCLA is ~53 hrs infeasible
let oneDriver = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(oneDriver == nil, "NYC→LA exceeds 5x daily limit for 1 driver")
// 2 drivers, 8 hrs each 16 hrs/day max 80 hrs feasible
let twoDriverConstraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
let twoDrivers = ItineraryBuilder.build(stops: stops, constraints: twoDriverConstraints)
#expect(twoDrivers != nil, "NYC→LA should build with 2 drivers")
if let built = twoDrivers {
#expect(built.travelSegments.count == 1)
#expect(built.travelSegments[0].estimatedDistanceMiles > 2000,
"NYC→LA should be 2000+ miles")
}
}
@Test("Multi-stop trip never has mismatched segment count")
func multiStopTrip_neverMismatched() {
// Property test: for any number of stops 2-10, segments == stops - 1
let allCities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit",
"Atlanta", "Miami", "Houston", "Denver", "Minneapolis"]
for stopCount in 2...min(10, allCities.count) {
let cities = Array(allCities.prefix(stopCount))
let stops = cities.enumerated().map { i, city in
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
}
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
if let built = result {
#expect(built.travelSegments.count == stopCount - 1,
"\(stopCount) stops must produce \(stopCount - 1) segments, got \(built.travelSegments.count)")
}
// nil is acceptable (infeasible), but never partial
}
}
@Test("Every travel segment has positive distance when cities differ")
func everySegment_hasPositiveDistance() {
let cities = ["New York", "Boston", "Philadelphia", "Chicago"]
let stops = cities.enumerated().map { i, city in
makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i)
}
let result = ItineraryBuilder.build(stops: stops, constraints: .default)
#expect(result != nil)
for (i, segment) in result!.travelSegments.enumerated() {
#expect(segment.estimatedDistanceMiles > 0,
"Segment \(i) (\(segment.fromLocation.name)\(segment.toLocation.name)) must have positive distance")
#expect(segment.estimatedDrivingHours > 0,
"Segment \(i) must have positive driving hours")
}
}
@Test("Segment from/to locations match adjacent stops")
func segmentEndpoints_matchAdjacentStops() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let cities = ["New York", "Boston", "Philadelphia"]
let games = cities.enumerated().map { i, city in
TestFixtures.game(
city: city,
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
)
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!
)
let request = PlanningRequest(
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for i in 0..<option.travelSegments.count {
let segment = option.travelSegments[i]
let fromStop = option.stops[i]
let toStop = option.stops[i + 1]
// Segment endpoints should match stop cities
#expect(segment.fromLocation.name == fromStop.city,
"Segment \(i) from '\(segment.fromLocation.name)' should match stop '\(fromStop.city)'")
#expect(segment.toLocation.name == toStop.city,
"Segment \(i) to '\(segment.toLocation.name)' should match stop '\(toStop.city)'")
}
}
}
}
// MARK: - Stress Tests
@Suite("Travel Integrity: Stress Tests")
struct TravelIntegrity_StressTests {
@Test("Large game set: all options still have valid travel")
func largeGameSet_allOptionsValid() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
// 15 games across 5 cities over 2 weeks
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Atlanta"]
var games: [Game] = []
for i in 0..<15 {
let city = cities[i % cities.count]
let date = TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
games.append(TestFixtures.game(city: city, dateTime: date))
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 15, to: baseDate)!
)
let request = PlanningRequest(
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "A-large")
}
@Test("All scenarios with allowRepeatCities=false still have valid travel")
func noRepeatCities_stillValidTravel() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let games = ["New York", "Boston", "Philadelphia", "Chicago"].enumerated().map { i, city in
TestFixtures.game(
city: city,
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
)
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
allowRepeatCities: false
)
let request = PlanningRequest(
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "A-noRepeat")
}
@Test("Scenario with must-stop constraint still has valid travel")
func mustStop_stillValidTravel() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let games = ["New York", "Boston", "Philadelphia"].enumerated().map { i, city in
TestFixtures.game(
city: city,
dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
)
}
let stadiums = TestFixtures.stadiumMap(for: games)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: baseDate,
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!,
mustStopLocations: [LocationInput(name: "Boston")]
)
let request = PlanningRequest(
preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
assertAllOptionsHaveValidTravel(result, scenario: "A-mustStop")
}
}
// MARK: - Test Helpers
/// Asserts that ALL options in a result have valid travel segments.
/// This is THE critical assertion for this test file.
private func assertAllOptionsHaveValidTravel(
_ result: ItineraryResult,
scenario: String,
sourceLocation: SourceLocation = #_sourceLocation
) {
guard case .success(let options) = result else {
// Failure is OK means engine couldn't find valid routes
// What's NOT OK is returning invalid success
return
}
#expect(!options.isEmpty, "Scenario \(scenario): success should have options",
sourceLocation: sourceLocation)
for (i, option) in options.enumerated() {
// THE CRITICAL CHECK
#expect(option.isValid,
"Scenario \(scenario) option \(i): \(option.stops.count) stops must have \(max(0, option.stops.count - 1)) segments, got \(option.travelSegments.count)",
sourceLocation: sourceLocation)
// Additional checks
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Scenario \(scenario) option \(i): segment count mismatch",
sourceLocation: sourceLocation)
// Every segment must have non-negative distance
for (j, seg) in option.travelSegments.enumerated() {
#expect(seg.estimatedDistanceMiles >= 0,
"Scenario \(scenario) option \(i) segment \(j): negative distance",
sourceLocation: sourceLocation)
}
}
}
}
/// Helper to create a basic ItineraryStop for testing.
private func makeStop(
city: String,
coord: CLLocationCoordinate2D?,
day: Int
) -> ItineraryStop {
let date = TestClock.addingDays(day)
return ItineraryStop(
city: city,
state: TestFixtures.states[city] ?? "",
coordinate: coord,
games: ["game_\(city.lowercased())_\(day)"],
arrivalDate: date,
departureDate: date,
location: LocationInput(name: city, coordinate: coord),
firstGameStart: date
)
}