// // TravelEstimatorTests.swift // SportsTimeTests // // 50 comprehensive tests for TravelEstimator covering: // - Haversine distance calculations (miles and meters) // - Travel segment estimation from stops // - Travel segment estimation from LocationInputs // - Fallback distance when coordinates missing // - Travel day calculations // - Edge cases and boundary conditions // import Testing @testable import SportsTime import Foundation import CoreLocation // MARK: - TravelEstimator Tests struct TravelEstimatorTests { // MARK: - Test Data Helpers private func makeStop( city: String, latitude: Double? = nil, longitude: Double? = nil, arrivalDate: Date = Date(), departureDate: Date? = nil ) -> ItineraryStop { let coordinate = (latitude != nil && longitude != nil) ? CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!) : nil let location = LocationInput( name: city, coordinate: coordinate, address: nil ) return ItineraryStop( city: city, state: "ST", coordinate: coordinate, games: [], arrivalDate: arrivalDate, departureDate: departureDate ?? arrivalDate, location: location, firstGameStart: nil ) } private func makeLocation( name: String, latitude: Double? = nil, longitude: Double? = nil ) -> LocationInput { let coordinate = (latitude != nil && longitude != nil) ? CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!) : nil return LocationInput(name: name, coordinate: coordinate, address: nil) } private func defaultConstraints() -> DrivingConstraints { DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) } private func twoDriverConstraints() -> DrivingConstraints { DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) } // MARK: - Haversine Distance (Miles) Tests @Test("haversineDistanceMiles - same point returns zero") func haversine_SamePoint_ReturnsZero() { let coord = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0) let distance = TravelEstimator.haversineDistanceMiles(from: coord, to: coord) #expect(distance == 0.0) } @Test("haversineDistanceMiles - LA to SF approximately 350 miles") func haversine_LAToSF_ApproximatelyCorrect() { let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) let sf = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) let distance = TravelEstimator.haversineDistanceMiles(from: la, to: sf) // Known distance is ~347 miles #expect(distance > 340 && distance < 360, "Expected ~350 miles, got \(distance)") } @Test("haversineDistanceMiles - NY to LA approximately 2450 miles") func haversine_NYToLA_ApproximatelyCorrect() { let ny = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060) let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) let distance = TravelEstimator.haversineDistanceMiles(from: ny, to: la) // Known distance is ~2450 miles #expect(distance > 2400 && distance < 2500, "Expected ~2450 miles, got \(distance)") } @Test("haversineDistanceMiles - commutative (A to B equals B to A)") func haversine_Commutative() { let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0) let coord2 = CLLocationCoordinate2D(latitude: 35.0, longitude: -90.0) let distance1 = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2) let distance2 = TravelEstimator.haversineDistanceMiles(from: coord2, to: coord1) #expect(abs(distance1 - distance2) < 0.001) } @Test("haversineDistanceMiles - across equator") func haversine_AcrossEquator() { let north = CLLocationCoordinate2D(latitude: 10.0, longitude: -80.0) let south = CLLocationCoordinate2D(latitude: -10.0, longitude: -80.0) let distance = TravelEstimator.haversineDistanceMiles(from: north, to: south) // 20 degrees latitude ≈ 1380 miles #expect(distance > 1350 && distance < 1400, "Expected ~1380 miles, got \(distance)") } @Test("haversineDistanceMiles - across prime meridian") func haversine_AcrossPrimeMeridian() { let west = CLLocationCoordinate2D(latitude: 51.5, longitude: -1.0) let east = CLLocationCoordinate2D(latitude: 51.5, longitude: 1.0) let distance = TravelEstimator.haversineDistanceMiles(from: west, to: east) // 2 degrees longitude at ~51.5° latitude ≈ 85 miles #expect(distance > 80 && distance < 90, "Expected ~85 miles, got \(distance)") } @Test("haversineDistanceMiles - near north pole") func haversine_NearNorthPole() { let coord1 = CLLocationCoordinate2D(latitude: 89.0, longitude: 0.0) let coord2 = CLLocationCoordinate2D(latitude: 89.0, longitude: 180.0) let distance = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2) // At 89° latitude, half way around the world is very short #expect(distance > 0 && distance < 150, "Distance near pole should be short, got \(distance)") } @Test("haversineDistanceMiles - Chicago to Denver approximately 920 miles") func haversine_ChicagoToDenver() { let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298) let denver = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) let distance = TravelEstimator.haversineDistanceMiles(from: chicago, to: denver) // Known distance ~920 miles #expect(distance > 900 && distance < 940, "Expected ~920 miles, got \(distance)") } @Test("haversineDistanceMiles - very short distance (same city)") func haversine_VeryShortDistance() { let point1 = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // Times Square let point2 = CLLocationCoordinate2D(latitude: 40.7614, longitude: -73.9776) // Grand Central let distance = TravelEstimator.haversineDistanceMiles(from: point1, to: point2) // ~0.5 miles #expect(distance > 0.4 && distance < 0.6, "Expected ~0.5 miles, got \(distance)") } @Test("haversineDistanceMiles - extreme longitude difference") func haversine_ExtremeLongitudeDifference() { let west = CLLocationCoordinate2D(latitude: 40.0, longitude: -179.0) let east = CLLocationCoordinate2D(latitude: 40.0, longitude: 179.0) let distance = TravelEstimator.haversineDistanceMiles(from: west, to: east) // 358 degrees the long way, 2 degrees the short way // At 40° latitude, 2 degrees ≈ 105 miles #expect(distance > 100 && distance < 110, "Expected ~105 miles, got \(distance)") } // MARK: - Haversine Distance (Meters) Tests @Test("haversineDistanceMeters - same point returns zero") func haversineMeters_SamePoint_ReturnsZero() { let coord = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0) let distance = TravelEstimator.haversineDistanceMeters(from: coord, to: coord) #expect(distance == 0.0) } @Test("haversineDistanceMeters - LA to SF approximately 560 km") func haversineMeters_LAToSF() { let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) let sf = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) let distanceKm = TravelEstimator.haversineDistanceMeters(from: la, to: sf) / 1000 #expect(distanceKm > 540 && distanceKm < 580, "Expected ~560 km, got \(distanceKm)") } @Test("haversineDistanceMeters - consistency with miles conversion") func haversineMeters_ConsistentWithMiles() { let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0) let coord2 = CLLocationCoordinate2D(latitude: 35.0, longitude: -90.0) let miles = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2) let meters = TravelEstimator.haversineDistanceMeters(from: coord1, to: coord2) // 1 mile = 1609.34 meters let milesFromMeters = meters / 1609.34 #expect(abs(miles - milesFromMeters) < 1.0) } @Test("haversineDistanceMeters - one kilometer distance") func haversineMeters_OneKilometer() { // 1 degree latitude ≈ 111 km, so 0.009 degrees ≈ 1 km let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0) let coord2 = CLLocationCoordinate2D(latitude: 40.009, longitude: -100.0) let meters = TravelEstimator.haversineDistanceMeters(from: coord1, to: coord2) #expect(meters > 900 && meters < 1100, "Expected ~1000 meters, got \(meters)") } // MARK: - Calculate Distance Miles Tests @Test("calculateDistanceMiles - with coordinates uses haversine") func calculateDistance_WithCoordinates_UsesHaversine() { let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let stop2 = makeStop(city: "San Francisco", latitude: 37.7749, longitude: -122.4194) let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2) // Haversine ~350 miles * 1.3 routing factor ≈ 455 miles #expect(distance > 440 && distance < 470, "Expected ~455 miles with routing factor, got \(distance)") } @Test("calculateDistanceMiles - without coordinates uses fallback") func calculateDistance_WithoutCoordinates_UsesFallback() { let stop1 = makeStop(city: "CityA") let stop2 = makeStop(city: "CityB") let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2) // Fallback is 300 miles #expect(distance == 300.0, "Expected fallback of 300 miles, got \(distance)") } @Test("calculateDistanceMiles - same city returns zero") func calculateDistance_SameCity_ReturnsZero() { let stop1 = makeStop(city: "Chicago") let stop2 = makeStop(city: "Chicago") let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2) #expect(distance == 0.0) } @Test("calculateDistanceMiles - one stop missing coordinates uses fallback") func calculateDistance_OneMissingCoordinate_UsesFallback() { let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let stop2 = makeStop(city: "San Francisco") let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2) #expect(distance == 300.0, "Expected fallback of 300 miles, got \(distance)") } // MARK: - Estimate Fallback Distance Tests @Test("estimateFallbackDistance - same city returns zero") func fallbackDistance_SameCity_ReturnsZero() { let stop1 = makeStop(city: "Denver") let stop2 = makeStop(city: "Denver") let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2) #expect(distance == 0.0) } @Test("estimateFallbackDistance - different cities returns 300") func fallbackDistance_DifferentCities_Returns300() { let stop1 = makeStop(city: "Denver") let stop2 = makeStop(city: "Chicago") let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2) #expect(distance == 300.0) } @Test("estimateFallbackDistance - case sensitive city names") func fallbackDistance_CaseSensitive() { let stop1 = makeStop(city: "denver") let stop2 = makeStop(city: "Denver") let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2) // Different case means different cities #expect(distance == 300.0) } // MARK: - Estimate (from Stops) Tests @Test("estimate stops - returns valid segment for short trip") func estimateStops_ShortTrip_ReturnsSegment() { let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let stop2 = makeStop(city: "San Diego", latitude: 32.7157, longitude: -117.1611) let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) #expect(segment != nil, "Should return segment for short trip") #expect(segment!.travelMode == .drive) #expect(segment!.durationHours < 8.0, "LA to SD should be under 8 hours") } @Test("estimate stops - returns nil for extremely long trip") func estimateStops_ExtremelyLongTrip_ReturnsNil() { // Create stops 4000 miles apart (> 2 days of driving at 60mph) let stop1 = makeStop(city: "New York", latitude: 40.7128, longitude: -74.0060) // Point way out in the Pacific let stop2 = makeStop(city: "Far Away", latitude: 35.0, longitude: -170.0) let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) #expect(segment == nil, "Should return nil for trip > 2 days of driving") } @Test("estimate stops - respects two-driver constraint") func estimateStops_TwoDrivers_IncreasesCapacity() { // Trip that exceeds 1-driver limit (16h) but fits 2-driver limit (32h) // LA to Denver: ~850mi straight line * 1.3 routing = ~1105mi / 60mph = ~18.4 hours let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let stop2 = makeStop(city: "Denver", latitude: 39.7392, longitude: -104.9903) let oneDriver = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) let twoDrivers = TravelEstimator.estimate(from: stop1, to: stop2, constraints: twoDriverConstraints()) // ~18 hours exceeds 1-driver limit (16h max over 2 days) but fits 2-driver (32h) #expect(oneDriver == nil, "Should fail with one driver - exceeds 16h limit") #expect(twoDrivers != nil, "Should succeed with two drivers - within 32h limit") } @Test("estimate stops - calculates departure and arrival times") func estimateStops_CalculatesTimes() { let baseDate = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))! let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437, departureDate: baseDate) let stop2 = makeStop(city: "San Diego", latitude: 32.7157, longitude: -117.1611) let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) #expect(segment != nil) #expect(segment!.departureTime > baseDate, "Departure should be after base date (adds 8 hours)") #expect(segment!.arrivalTime > segment!.departureTime, "Arrival should be after departure") } @Test("estimate stops - distance and duration are consistent") func estimateStops_DistanceDurationConsistent() { let stop1 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298) let stop2 = makeStop(city: "Detroit", latitude: 42.3314, longitude: -83.0458) let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) #expect(segment != nil) // At 60 mph average, hours = miles / 60 let expectedHours = segment!.distanceMiles / 60.0 #expect(abs(segment!.durationHours - expectedHours) < 0.01) } @Test("estimate stops - zero distance same location") func estimateStops_SameLocation_ZeroDistance() { let stop1 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298) let stop2 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298) let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints()) #expect(segment != nil) #expect(segment!.distanceMiles == 0.0) #expect(segment!.durationHours == 0.0) } // MARK: - Estimate (from LocationInputs) Tests @Test("estimate locations - returns valid segment") func estimateLocations_ValidLocations_ReturnsSegment() { let from = makeLocation(name: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let to = makeLocation(name: "San Diego", latitude: 32.7157, longitude: -117.1611) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment != nil) #expect(segment!.fromLocation.name == "Los Angeles") #expect(segment!.toLocation.name == "San Diego") } @Test("estimate locations - returns nil for missing from coordinate") func estimateLocations_MissingFromCoordinate_ReturnsNil() { let from = makeLocation(name: "Unknown City") let to = makeLocation(name: "San Diego", latitude: 32.7157, longitude: -117.1611) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment == nil) } @Test("estimate locations - returns nil for missing to coordinate") func estimateLocations_MissingToCoordinate_ReturnsNil() { let from = makeLocation(name: "Los Angeles", latitude: 34.0522, longitude: -118.2437) let to = makeLocation(name: "Unknown City") let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment == nil) } @Test("estimate locations - returns nil for both missing coordinates") func estimateLocations_BothMissingCoordinates_ReturnsNil() { let from = makeLocation(name: "Unknown A") let to = makeLocation(name: "Unknown B") let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment == nil) } @Test("estimate locations - applies road routing factor") func estimateLocations_AppliesRoutingFactor() { let from = makeLocation(name: "A", latitude: 40.0, longitude: -100.0) let to = makeLocation(name: "B", latitude: 41.0, longitude: -100.0) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment != nil) // Straight line distance * 1.3 routing factor let straightLineMeters = TravelEstimator.haversineDistanceMeters( from: from.coordinate!, to: to.coordinate! ) let expectedMeters = straightLineMeters * 1.3 #expect(abs(segment!.distanceMeters - expectedMeters) < 1.0) } @Test("estimate locations - returns nil for extremely long trip") func estimateLocations_ExtremelyLongTrip_ReturnsNil() { let from = makeLocation(name: "New York", latitude: 40.7128, longitude: -74.0060) let to = makeLocation(name: "Far Pacific", latitude: 35.0, longitude: -170.0) let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints()) #expect(segment == nil) } // MARK: - Calculate Travel Days Tests @Test("calculateTravelDays - short trip returns single day") func travelDays_ShortTrip_ReturnsOneDay() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 4.0) #expect(days.count == 1) } @Test("calculateTravelDays - exactly 8 hours returns single day") func travelDays_EightHours_ReturnsOneDay() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 8.0) #expect(days.count == 1) } @Test("calculateTravelDays - 9 hours returns two days") func travelDays_NineHours_ReturnsTwoDays() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 9.0) #expect(days.count == 2) } @Test("calculateTravelDays - 16 hours returns two days") func travelDays_SixteenHours_ReturnsTwoDays() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 16.0) #expect(days.count == 2) } @Test("calculateTravelDays - 17 hours returns three days") func travelDays_SeventeenHours_ReturnsThreeDays() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 17.0) #expect(days.count == 3) } @Test("calculateTravelDays - zero hours returns single day") func travelDays_ZeroHours_ReturnsOneDay() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 0.0) // ceil(0 / 8) = 0, but we always start with one day #expect(days.count == 1) } @Test("calculateTravelDays - days are at start of day") func travelDays_DaysAreAtStartOfDay() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 14, minute: 30))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 10.0) #expect(days.count == 2) let cal = Calendar.current for day in days { let hour = cal.component(.hour, from: day) let minute = cal.component(.minute, from: day) #expect(hour == 0 && minute == 0, "Day should be at midnight") } } @Test("calculateTravelDays - consecutive days are correct") func travelDays_ConsecutiveDays() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 20.0) #expect(days.count == 3) let cal = Calendar.current #expect(cal.component(.day, from: days[0]) == 5) #expect(cal.component(.day, from: days[1]) == 6) #expect(cal.component(.day, from: days[2]) == 7) } @Test("calculateTravelDays - handles month boundary") func travelDays_HandleMonthBoundary() { let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 30, hour: 8))! let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 10.0) #expect(days.count == 2) let cal = Calendar.current #expect(cal.component(.month, from: days[0]) == 4) #expect(cal.component(.day, from: days[0]) == 30) #expect(cal.component(.month, from: days[1]) == 5) #expect(cal.component(.day, from: days[1]) == 1) } // MARK: - Driving Constraints Tests @Test("DrivingConstraints - default values") func constraints_DefaultValues() { let constraints = DrivingConstraints.default #expect(constraints.numberOfDrivers == 1) #expect(constraints.maxHoursPerDriverPerDay == 8.0) #expect(constraints.maxDailyDrivingHours == 8.0) } @Test("DrivingConstraints - multiple drivers increase daily limit") func constraints_MultipleDrivers() { let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) #expect(constraints.maxDailyDrivingHours == 16.0) } @Test("DrivingConstraints - custom hours per driver") func constraints_CustomHoursPerDriver() { let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 10.0) #expect(constraints.maxDailyDrivingHours == 10.0) } @Test("DrivingConstraints - enforces minimum 1 driver") func constraints_MinimumOneDriver() { let constraints = DrivingConstraints(numberOfDrivers: 0, maxHoursPerDriverPerDay: 8.0) #expect(constraints.numberOfDrivers == 1) } @Test("DrivingConstraints - enforces minimum 1 hour") func constraints_MinimumOneHour() { let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0.5) #expect(constraints.maxHoursPerDriverPerDay == 1.0) } @Test("DrivingConstraints - from preferences") func constraints_FromPreferences() { var prefs = TripPreferences() prefs.numberOfDrivers = 3 prefs.maxDrivingHoursPerDriver = 6.0 let constraints = DrivingConstraints(from: prefs) #expect(constraints.numberOfDrivers == 3) #expect(constraints.maxHoursPerDriverPerDay == 6.0) #expect(constraints.maxDailyDrivingHours == 18.0) } @Test("DrivingConstraints - from preferences with nil hours uses default") func constraints_FromPreferencesNilHours() { var prefs = TripPreferences() prefs.numberOfDrivers = 2 prefs.maxDrivingHoursPerDriver = nil let constraints = DrivingConstraints(from: prefs) #expect(constraints.maxHoursPerDriverPerDay == 8.0) } }