Files
Sportstime/SportsTimeTests/Planning/ItineraryBuilderTests.swift
Trey T 6cbcef47ae Add implementation code for all 4 improvement plan phases
Production changes:
- TravelEstimator: remove 300mi fallback, return nil on missing coords
- TripPlanningEngine: add warnings array, empty sports warning, inverted
  date range rejection, must-stop filter, segment validation gate
- GameDAGRouter: add routePreference parameter with preference-aware
  bucket ordering and sorting in selectDiverseRoutes()
- ScenarioA-E: pass routePreference through to GameDAGRouter
- ScenarioA: track games with missing stadium data
- ScenarioE: add region filtering for home games
- TravelSegment: add requiresOvernightStop and travelDays() helpers

Test changes:
- GameDAGRouterTests: +252 lines for route preference verification
- TripPlanningEngineTests: +153 lines for segment validation, date range,
  empty sports
- ScenarioEPlannerTests: +119 lines for region filter tests
- TravelEstimatorTests: remove obsolete fallback distance tests
- ItineraryBuilderTests: update nil-coords test expectation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:40:32 -05:00

369 lines
13 KiB
Swift

//
// ItineraryBuilderTests.swift
// SportsTimeTests
//
// TDD specification + property tests for ItineraryBuilder.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ItineraryBuilder")
struct ItineraryBuilderTests {
// MARK: - Test Data
private let constraints = DrivingConstraints.default // 1 driver, 8 hrs/day
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let seattleCoord = CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3316)
private let calendar = TestClock.calendar
// MARK: - Specification Tests: build()
@Test("build: empty stops returns empty itinerary")
func build_emptyStops_returnsEmptyItinerary() {
let result = ItineraryBuilder.build(stops: [], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.isEmpty == true)
#expect(result?.travelSegments.isEmpty == true)
#expect(result?.totalDrivingHours == 0)
#expect(result?.totalDistanceMiles == 0)
}
@Test("build: single stop returns single-stop itinerary")
func build_singleStop_returnsSingleStopItinerary() {
let stop = makeStop(city: "New York", coordinate: nycCoord)
let result = ItineraryBuilder.build(stops: [stop], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 1)
#expect(result?.travelSegments.isEmpty == true)
#expect(result?.totalDrivingHours == 0)
#expect(result?.totalDistanceMiles == 0)
}
@Test("build: two stops creates one segment")
func build_twoStops_createsOneSegment() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 2)
#expect(result?.travelSegments.count == 1)
#expect(result?.totalDrivingHours ?? 0 > 0)
#expect(result?.totalDistanceMiles ?? 0 > 0)
}
@Test("build: three stops creates two segments")
func build_threeStops_createsTwoSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 3)
#expect(result?.travelSegments.count == 2)
}
@Test("build: totalDrivingHours is sum of segments")
func build_totalDrivingHours_isSumOfSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)!
let segmentHours = result.travelSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours }
#expect(abs(result.totalDrivingHours - segmentHours) < 0.01)
}
@Test("build: totalDistanceMiles is sum of segments")
func build_totalDistanceMiles_isSumOfSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)!
let segmentMiles = result.travelSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles }
#expect(abs(result.totalDistanceMiles - segmentMiles) < 0.01)
}
@Test("build: infeasible segment returns nil")
func build_infeasibleSegment_returnsNil() {
// NYC to Seattle is ~2850 miles, ~47 hours driving
// With 1 driver at 8 hrs/day, max is 40 hours (5 days)
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Seattle", coordinate: seattleCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
#expect(result == nil)
}
@Test("build: feasible with more drivers succeeds")
func build_feasibleWithMoreDrivers_succeeds() {
// NYC to Seattle with 2 drivers: max is 80 hours (2*8*5)
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Seattle", coordinate: seattleCoord)
let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: twoDrivers)
#expect(result != nil)
}
// MARK: - Specification Tests: Custom Validator
@Test("build: validator returning true allows segment")
func build_validatorReturnsTrue_allowsSegment() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let alwaysValid: ItineraryBuilder.SegmentValidator = { _, _, _ in true }
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: alwaysValid
)
#expect(result != nil)
}
@Test("build: validator returning false rejects itinerary")
func build_validatorReturnsFalse_rejectsItinerary() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let alwaysInvalid: ItineraryBuilder.SegmentValidator = { _, _, _ in false }
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: alwaysInvalid
)
#expect(result == nil)
}
@Test("build: validator receives correct stops")
func build_validatorReceivesCorrectStops() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
var capturedFromCity: String?
var capturedToCity: String?
let captureValidator: ItineraryBuilder.SegmentValidator = { _, fromStop, toStop in
capturedFromCity = fromStop.city
capturedToCity = toStop.city
return true
}
_ = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: captureValidator
)
#expect(capturedFromCity == "New York")
#expect(capturedToCity == "Boston")
}
// MARK: - Specification Tests: arrivalBeforeGameStart Validator
@Test("arrivalBeforeGameStart: no game start time always passes")
func arrivalBeforeGameStart_noGameStart_alwaysPasses() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord, firstGameStart: nil)
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
#expect(result != nil)
}
@Test("arrivalBeforeGameStart: sufficient time passes")
func arrivalBeforeGameStart_sufficientTime_passes() {
let now = TestClock.now
let tomorrow = calendar.date(byAdding: .day, value: 1, to: now)!
let gameTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: tomorrow)!
let stop1 = makeStop(
city: "New York",
coordinate: nycCoord,
departureDate: now
)
let stop2 = makeStop(
city: "Boston",
coordinate: bostonCoord,
firstGameStart: gameTime
)
// NYC to Boston is ~4 hours, game is tomorrow at 7pm, plenty of time
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
#expect(result != nil)
}
@Test("arrivalBeforeGameStart: insufficient time fails")
func arrivalBeforeGameStart_insufficientTime_fails() {
let now = TestClock.now
let gameTime = now.addingTimeInterval(2 * 3600) // Game in 2 hours
let stop1 = makeStop(
city: "New York",
coordinate: nycCoord,
departureDate: now
)
let stop2 = makeStop(
city: "Chicago", // ~13 hours away
coordinate: chicagoCoord,
firstGameStart: gameTime
)
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
// Should fail because we can't get to Chicago in 2 hours
// (assuming the segment is even feasible, which it isn't for 1 driver)
// Either the segment is infeasible OR the validator rejects it
#expect(result == nil)
}
// MARK: - Property Tests
@Test("Property: segments count equals stops count minus one")
func property_segmentsCountEqualsStopsMinusOne() {
for count in [2, 3, 4, 5] {
let stops = (0..<count).map { i in
makeStop(city: "City\(i)", coordinate: nycCoord) // Same coord for all = always feasible
}
if let result = ItineraryBuilder.build(stops: stops, constraints: constraints) {
#expect(result.travelSegments.count == stops.count - 1,
"For \(count) stops, should have \(count - 1) segments")
}
}
}
@Test("Property: totals are non-negative")
func property_totalsNonNegative() {
let stops = [
makeStop(city: "New York", coordinate: nycCoord),
makeStop(city: "Philadelphia", coordinate: phillyCoord)
]
if let result = ItineraryBuilder.build(stops: stops, constraints: constraints) {
#expect(result.totalDrivingHours >= 0)
#expect(result.totalDistanceMiles >= 0)
}
}
@Test("Property: empty/single stop always succeeds")
func property_emptyOrSingleStopAlwaysSucceeds() {
let emptyResult = ItineraryBuilder.build(stops: [], constraints: constraints)
#expect(emptyResult != nil)
let singleResult = ItineraryBuilder.build(
stops: [makeStop(city: "NYC", coordinate: nycCoord)],
constraints: constraints
)
#expect(singleResult != nil)
}
// MARK: - Edge Case Tests
@Test("Edge: stops with nil coordinates are infeasible")
func edge_nilCoordinates_infeasible() {
let stop1 = makeStop(city: "City1", coordinate: nil)
let stop2 = makeStop(city: "City2", coordinate: nil)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
// Missing coordinates = infeasible (safer to skip than show wrong drive time)
#expect(result == nil, "Stops with missing coordinates should be infeasible")
}
@Test("Edge: same city stops have zero distance")
func edge_sameCityStops_zeroDistance() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "New York", coordinate: nycCoord) // Same location
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
#expect(result != nil)
// Same coordinates should result in ~0 distance
#expect(result?.totalDistanceMiles ?? 1000 < 1)
}
@Test("Edge: very long trip is still feasible with multiple drivers")
func edge_veryLongTrip_feasibleWithMultipleDrivers() {
// NYC -> Chicago -> Seattle
let stops = [
makeStop(city: "New York", coordinate: nycCoord),
makeStop(city: "Chicago", coordinate: chicagoCoord),
makeStop(city: "Seattle", coordinate: seattleCoord)
]
// With 3 drivers, max is 120 hours (3*8*5)
let threeDrivers = DrivingConstraints(numberOfDrivers: 3, maxHoursPerDriverPerDay: 8.0)
let result = ItineraryBuilder.build(stops: stops, constraints: threeDrivers)
#expect(result != nil)
}
// MARK: - Helper Methods
private func makeStop(
city: String,
coordinate: CLLocationCoordinate2D?,
departureDate: Date = TestClock.now,
firstGameStart: Date? = nil
) -> ItineraryStop {
ItineraryStop(
city: city,
state: "XX",
coordinate: coordinate,
games: [],
arrivalDate: departureDate,
departureDate: departureDate,
location: LocationInput(
name: city,
coordinate: coordinate
),
firstGameStart: firstGameStart
)
}
}