// // 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 = Calendar.current // 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 = Date() 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 = Date() 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..= 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 use fallback") func edge_nilCoordinates_useFallback() { let stop1 = makeStop(city: "City1", coordinate: nil) let stop2 = makeStop(city: "City2", coordinate: nil) let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints) // Should use fallback distance (300 miles) #expect(result != nil) #expect(result?.totalDistanceMiles ?? 0 > 0) } @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 = Date(), firstGameStart: Date? = nil ) -> ItineraryStop { ItineraryStop( city: city, state: "XX", coordinate: coordinate, games: [], arrivalDate: departureDate, departureDate: departureDate, location: LocationInput( name: city, coordinate: coordinate ), firstGameStart: firstGameStart ) } }