// // TravelEstimatorTests.swift // SportsTimeTests // // TDD specification + property tests for TravelEstimator. // import Testing import CoreLocation @testable import SportsTime @Suite("TravelEstimator") struct TravelEstimatorTests { // MARK: - Test Data private let nyc = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) private let boston = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) private let chicago = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233) private let losAngeles = CLLocationCoordinate2D(latitude: 34.0141, longitude: -118.2879) private let seattle = CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3316) private let defaultConstraints = DrivingConstraints.default // 1 driver, 8 hrs/day // MARK: - Specification Tests: haversineDistanceMiles @Test("haversineDistanceMiles: same point returns zero") func haversineDistanceMiles_samePoint_returnsZero() { let distance = TravelEstimator.haversineDistanceMiles(from: nyc, to: nyc) #expect(distance == 0) } @Test("haversineDistanceMiles: NYC to Boston approximately 190 miles") func haversineDistanceMiles_nycToBoston_approximately190() { let distance = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) // NYC to Boston is approximately 190 miles as the crow flies #expect(distance > 180 && distance < 200) } @Test("haversineDistanceMiles: NYC to LA approximately 2450 miles") func haversineDistanceMiles_nycToLA_approximately2450() { let distance = TravelEstimator.haversineDistanceMiles(from: nyc, to: losAngeles) // NYC to LA is approximately 2450 miles as the crow flies #expect(distance > 2400 && distance < 2500) } @Test("haversineDistanceMiles: symmetric - distance(A,B) equals distance(B,A)") func haversineDistanceMiles_symmetric() { let distanceAB = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) let distanceBA = TravelEstimator.haversineDistanceMiles(from: boston, to: nyc) #expect(distanceAB == distanceBA) } // MARK: - Specification Tests: haversineDistanceMeters @Test("haversineDistanceMeters: same point returns zero") func haversineDistanceMeters_samePoint_returnsZero() { let distance = TravelEstimator.haversineDistanceMeters(from: nyc, to: nyc) #expect(distance == 0) } @Test("haversineDistanceMeters: consistent with miles calculation") func haversineDistanceMeters_consistentWithMiles() { let meters = TravelEstimator.haversineDistanceMeters(from: nyc, to: boston) let miles = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) // Convert meters to miles: 1 mile = 1609.34 meters let convertedMiles = meters / 1609.34 #expect(abs(convertedMiles - miles) < 1.0) // Within 1 mile tolerance } // MARK: - Specification Tests: estimateFallbackDistance @Test("estimateFallbackDistance: same city returns zero") func estimateFallbackDistance_sameCity_returnsZero() { let from = makeStop(city: "New York") let to = makeStop(city: "New York") let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to) #expect(distance == 0) } @Test("estimateFallbackDistance: different cities returns 300 miles") func estimateFallbackDistance_differentCities_returns300() { let from = makeStop(city: "New York") let to = makeStop(city: "Boston") let distance = TravelEstimator.estimateFallbackDistance(from: from, to: to) #expect(distance == 300) } // MARK: - Specification Tests: calculateDistanceMiles @Test("calculateDistanceMiles: with coordinates uses Haversine times routing factor") func calculateDistanceMiles_withCoordinates_usesHaversineTimesRoutingFactor() { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to) let haversine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) // Road distance = Haversine * 1.3 #expect(abs(distance - haversine * 1.3) < 0.1) } @Test("calculateDistanceMiles: missing coordinates uses fallback") func calculateDistanceMiles_missingCoordinates_usesFallback() { let from = makeStop(city: "New York", coordinate: nil) let to = makeStop(city: "Boston", coordinate: nil) let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to) #expect(distance == 300) // Fallback distance } @Test("calculateDistanceMiles: same city without coordinates returns zero") func calculateDistanceMiles_sameCityNoCoords_returnsZero() { let from = makeStop(city: "New York", coordinate: nil) let to = makeStop(city: "New York", coordinate: nil) let distance = TravelEstimator.calculateDistanceMiles(from: from, to: to) #expect(distance == 0) } // MARK: - Specification Tests: estimate(ItineraryStop, ItineraryStop) @Test("estimate: valid coordinates returns TravelSegment") func estimate_validCoordinates_returnsTravelSegment() { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints) #expect(segment != nil) #expect(segment?.distanceMeters ?? 0 > 0) #expect(segment?.durationSeconds ?? 0 > 0) } @Test("estimate: distance and duration are calculated correctly") func estimate_distanceAndDuration_calculatedCorrectly() { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints)! let expectedMiles = TravelEstimator.calculateDistanceMiles(from: from, to: to) let expectedMeters = expectedMiles * 1609.34 let expectedHours = expectedMiles / 60.0 let expectedSeconds = expectedHours * 3600 #expect(abs(segment.distanceMeters - expectedMeters) < 100) // Within 100m #expect(abs(segment.durationSeconds - expectedSeconds) < 60) // Within 1 minute } @Test("estimate: exceeding max driving hours returns nil") func estimate_exceedingMaxDrivingHours_returnsNil() { // NYC to Seattle is ~2850 miles, ~47.5 hours driving // With 1 driver at 8 hrs/day, max is 40 hours (5 days) let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Seattle", coordinate: seattle) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints) #expect(segment == nil) } @Test("estimate: within max driving hours with multiple drivers succeeds") func estimate_withinMaxWithMultipleDrivers_succeeds() { // NYC to Seattle with 2 drivers: max is 80 hours (2*8*5) let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Seattle", coordinate: seattle) let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) let segment = TravelEstimator.estimate(from: from, to: to, constraints: twoDrivers) #expect(segment != nil) } // MARK: - Specification Tests: estimate(LocationInput, LocationInput) @Test("estimate LocationInput: missing from coordinate returns nil") func estimateLocationInput_missingFromCoordinate_returnsNil() { let from = LocationInput(name: "Unknown", coordinate: nil) let to = LocationInput(name: "Boston", coordinate: boston) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints) #expect(segment == nil) } @Test("estimate LocationInput: missing to coordinate returns nil") func estimateLocationInput_missingToCoordinate_returnsNil() { let from = LocationInput(name: "New York", coordinate: nyc) let to = LocationInput(name: "Unknown", coordinate: nil) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints) #expect(segment == nil) } @Test("estimate LocationInput: valid coordinates returns TravelSegment") func estimateLocationInput_validCoordinates_returnsTravelSegment() { let from = LocationInput(name: "New York", coordinate: nyc) let to = LocationInput(name: "Boston", coordinate: boston) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints) #expect(segment != nil) #expect(segment?.distanceMeters ?? 0 > 0) #expect(segment?.durationSeconds ?? 0 > 0) } // MARK: - Specification Tests: calculateTravelDays @Test("calculateTravelDays: zero hours returns departure day only") func calculateTravelDays_zeroHours_returnsDepartureDay() { let departure = Date() let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 0) #expect(days.count == 1) } @Test("calculateTravelDays: 1-8 hours returns single day") func calculateTravelDays_1to8Hours_returnsSingleDay() { let departure = Date() for hours in [1.0, 4.0, 7.0, 8.0] { let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: hours) #expect(days.count == 1, "Expected 1 day for \(hours) hours, got \(days.count)") } } @Test("calculateTravelDays: 8.01-16 hours returns two days") func calculateTravelDays_8to16Hours_returnsTwoDays() { let departure = Date() for hours in [8.01, 12.0, 16.0] { let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: hours) #expect(days.count == 2, "Expected 2 days for \(hours) hours, got \(days.count)") } } @Test("calculateTravelDays: 16.01-24 hours returns three days") func calculateTravelDays_16to24Hours_returnsThreeDays() { let departure = Date() for hours in [16.01, 20.0, 24.0] { let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: hours) #expect(days.count == 3, "Expected 3 days for \(hours) hours, got \(days.count)") } } @Test("calculateTravelDays: all dates are start of day") func calculateTravelDays_allDatesAreStartOfDay() { let calendar = Calendar.current // Use a specific time that's not midnight var components = calendar.dateComponents([.year, .month, .day], from: Date()) components.hour = 14 components.minute = 30 let departure = calendar.date(from: components)! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 20) for day in days { let hour = calendar.component(.hour, from: day) let minute = calendar.component(.minute, from: day) #expect(hour == 0 && minute == 0, "Expected midnight, got \(hour):\(minute)") } } @Test("calculateTravelDays: consecutive days") func calculateTravelDays_consecutiveDays() { let calendar = Calendar.current let departure = Date() let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 24) #expect(days.count == 3) for i in 1..= 0, "Distance should be non-negative") } } } @Test("Property: haversine distance is symmetric") func property_haversineDistanceSymmetric() { let coordinates = [nyc, boston, chicago, losAngeles, seattle] for from in coordinates { for to in coordinates { let distanceAB = TravelEstimator.haversineDistanceMiles(from: from, to: to) let distanceBA = TravelEstimator.haversineDistanceMiles(from: to, to: from) #expect(distanceAB == distanceBA, "Distance should be symmetric") } } } @Test("Property: triangle inequality holds") func property_triangleInequality() { // For any three points A, B, C: distance(A,C) <= distance(A,B) + distance(B,C) let a = nyc let b = chicago let c = losAngeles let ac = TravelEstimator.haversineDistanceMiles(from: a, to: c) let ab = TravelEstimator.haversineDistanceMiles(from: a, to: b) let bc = TravelEstimator.haversineDistanceMiles(from: b, to: c) #expect(ac <= ab + bc + 0.001, "Triangle inequality should hold") } @Test("Property: road distance >= straight line distance") func property_roadDistanceGreaterThanStraightLine() { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) let roadDistance = TravelEstimator.calculateDistanceMiles(from: from, to: to) let straightLine = TravelEstimator.haversineDistanceMiles(from: nyc, to: boston) #expect(roadDistance >= straightLine, "Road distance should be >= straight line") } @Test("Property: estimate duration proportional to distance") func property_durationProportionalToDistance() { let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Boston", coordinate: boston) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints)! // Duration should be distance / 60 mph let miles = segment.distanceMeters / 1609.34 let expectedHours = miles / 60.0 let actualHours = segment.durationSeconds / 3600.0 #expect(abs(actualHours - expectedHours) < 0.1, "Duration should be distance/60mph") } @Test("Property: more drivers allows longer trips") func property_moreDriversAllowsLongerTrips() { // NYC to LA is ~2450 miles, ~53 hours driving with routing factor let from = makeStop(city: "New York", coordinate: nyc) let to = makeStop(city: "Los Angeles", coordinate: losAngeles) let oneDriver = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) let withOne = TravelEstimator.estimate(from: from, to: to, constraints: oneDriver) let withTwo = TravelEstimator.estimate(from: from, to: to, constraints: twoDrivers) // With more drivers, trips that fail with one driver should succeed // (or both succeed/fail, but never one succeeds and more drivers fails) if withOne != nil { #expect(withTwo != nil, "More drivers should not reduce capability") } } // MARK: - Edge Case Tests @Test("Edge: antipodal points (maximum distance)") func edge_antipodalPoints() { // NYC to a point roughly opposite on Earth let antipode = CLLocationCoordinate2D( latitude: -nyc.latitude, longitude: nyc.longitude + 180 ) let distance = TravelEstimator.haversineDistanceMiles(from: nyc, to: antipode) // Half Earth circumference is about 12,450 miles #expect(distance > 12000 && distance < 13000) } @Test("Edge: very close points") func edge_veryClosePoints() { let nearby = CLLocationCoordinate2D( latitude: nyc.latitude + 0.0001, longitude: nyc.longitude + 0.0001 ) let distance = TravelEstimator.haversineDistanceMiles(from: nyc, to: nearby) #expect(distance < 0.1, "Very close points should have near-zero distance") } @Test("Edge: crossing prime meridian") func edge_crossingPrimeMeridian() { let london = CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278) let paris = CLLocationCoordinate2D(latitude: 48.8566, longitude: 2.3522) let distance = TravelEstimator.haversineDistanceMiles(from: london, to: paris) // London to Paris is about 213 miles #expect(distance > 200 && distance < 230) } @Test("Edge: crossing date line") func edge_crossingDateLine() { let tokyo = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) let honolulu = CLLocationCoordinate2D(latitude: 21.3069, longitude: -157.8583) let distance = TravelEstimator.haversineDistanceMiles(from: tokyo, to: honolulu) // Tokyo to Honolulu is about 3850 miles #expect(distance > 3700 && distance < 4000) } @Test("Edge: calculateTravelDays with exactly 8 hours") func edge_calculateTravelDays_exactly8Hours() { let departure = Date() let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 8.0) #expect(days.count == 1, "Exactly 8 hours should be 1 day") } @Test("Edge: calculateTravelDays just over 8 hours") func edge_calculateTravelDays_justOver8Hours() { let departure = Date() let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 8.001) #expect(days.count == 2, "Just over 8 hours should be 2 days") } @Test("Edge: negative driving hours treated as minimum 1 day") func edge_negativeDrivingHours() { let departure = Date() let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: -5) #expect(days.count >= 1, "Negative hours should still return at least 1 day") } // MARK: - Helper Methods private func makeStop( city: String, state: String = "XX", coordinate: CLLocationCoordinate2D? = nil ) -> ItineraryStop { ItineraryStop( city: city, state: state, coordinate: coordinate, games: [], arrivalDate: Date(), departureDate: Date(), location: LocationInput(name: city, coordinate: coordinate), firstGameStart: nil ) } }