// // TripPlanningEngineTests.swift // SportsTimeTests // // TDD specification tests for TripPlanningEngine. // import Testing import Foundation import CoreLocation @testable import SportsTime @Suite("TripPlanningEngine") struct TripPlanningEngineTests { // MARK: - Test Data private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233) private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673) // MARK: - Specification Tests: Driving Constraints @Test("DrivingConstraints: calculates maxDailyDrivingHours correctly") func drivingConstraints_maxDailyHours() { let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 6.0) #expect(constraints.maxDailyDrivingHours == 12.0) } @Test("DrivingConstraints: clamps negative drivers to 1") func drivingConstraints_clampsNegativeDrivers() { let constraints = DrivingConstraints(numberOfDrivers: -5, maxHoursPerDriverPerDay: 8.0) #expect(constraints.numberOfDrivers == 1) #expect(constraints.maxDailyDrivingHours == 8.0) } @Test("DrivingConstraints: clamps zero hours to minimum") func drivingConstraints_clampsZeroHours() { let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0) #expect(constraints.maxHoursPerDriverPerDay == 1.0) } // MARK: - Specification Tests: Trip Preferences Computed Properties @Test("totalDriverHoursPerDay: defaults to 8 hours when nil") func totalDriverHoursPerDay_default() { let prefs = TripPreferences( numberOfDrivers: 1, maxDrivingHoursPerDriver: nil ) #expect(prefs.totalDriverHoursPerDay == 8.0) } @Test("totalDriverHoursPerDay: multiplies by number of drivers") func totalDriverHoursPerDay_multipleDrivers() { let prefs = TripPreferences( numberOfDrivers: 2, maxDrivingHoursPerDriver: 6.0 ) #expect(prefs.totalDriverHoursPerDay == 12.0) } @Test("effectiveTripDuration: uses explicit tripDuration when set") func effectiveTripDuration_explicit() { let prefs = TripPreferences( tripDuration: 5 ) #expect(prefs.effectiveTripDuration == 5) } @Test("effectiveTripDuration: calculates from date range when tripDuration is nil") func effectiveTripDuration_calculated() { let calendar = TestClock.calendar let startDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))! let endDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))! let prefs = TripPreferences( startDate: startDate, endDate: endDate, tripDuration: nil ) #expect(prefs.effectiveTripDuration == 8) // 8 days inclusive (15th through 22nd) } // MARK: - Invariant Tests @Test("Invariant: totalDriverHoursPerDay > 0") func invariant_totalDriverHoursPositive() { let prefs1 = TripPreferences(numberOfDrivers: 1) #expect(prefs1.totalDriverHoursPerDay > 0) #expect(prefs1.totalDriverHoursPerDay == 8.0) // 1 driver × 8 hrs let prefs2 = TripPreferences(numberOfDrivers: 3, maxDrivingHoursPerDriver: 4) #expect(prefs2.totalDriverHoursPerDay > 0) #expect(prefs2.totalDriverHoursPerDay == 12.0) // 3 drivers × 4 hrs } @Test("Invariant: effectiveTripDuration >= 1") func invariant_effectiveTripDurationMinimum() { let testCases: [Int?] = [nil, 1, 5, 10] for duration in testCases { let prefs = TripPreferences(tripDuration: duration) #expect(prefs.effectiveTripDuration >= 1) } // Verify specific value for nil duration with default dates let prefsNil = TripPreferences(tripDuration: nil) #expect(prefsNil.effectiveTripDuration == 8) // Default 7-day range = 8 days inclusive } // MARK: - Travel Segment Validation @Test("planTrip: multi-stop result always has travel segments") func planTrip_multiStopResult_alwaysHasTravelSegments() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "Boston", dateTime: day2) let game3 = TestFixtures.game(city: "Philadelphia", dateTime: day3) let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3]) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day3 ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], 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 { #expect(option.isValid, "Every returned option must be valid (segments = stops - 1)") if option.stops.count > 1 { #expect(option.travelSegments.count == option.stops.count - 1) } } } @Test("planTrip: N stops always have exactly N-1 travel segments") func planTrip_nStops_haveExactlyNMinus1Segments() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) // Create 5 games across cities to produce routes of varying lengths let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit"] var games: [Game] = [] for (i, city) in cities.enumerated() { 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 endDate = TestClock.calendar.date(byAdding: .day, value: cities.count, to: baseDate)! let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: endDate ) 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 } #expect(!options.isEmpty, "Should produce at least one option") for option in options { if option.stops.count > 1 { #expect(option.travelSegments.count == option.stops.count - 1, "Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)") } else { #expect(option.travelSegments.isEmpty, "Single-stop option must have 0 segments") } } } @Test("ItineraryOption.isValid: correctly validates segment count") func planTrip_invalidOptions_areFilteredOut() { // Create a valid ItineraryOption manually with wrong segment count let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: nycCoord, games: ["g1"], arrivalDate: Date(), departureDate: Date(), location: LocationInput(name: "New York", coordinate: nycCoord), firstGameStart: Date() ) let stop2 = ItineraryStop( city: "Boston", state: "MA", coordinate: bostonCoord, games: ["g2"], arrivalDate: Date(), departureDate: Date(), location: LocationInput(name: "Boston", coordinate: bostonCoord), firstGameStart: Date() ) // Invalid: 2 stops but 0 segments let invalidOption = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [], totalDrivingHours: 0, totalDistanceMiles: 0, geographicRationale: "test" ) #expect(!invalidOption.isValid, "2 stops with 0 segments should be invalid") // Valid: 2 stops with 1 segment let segment = TestFixtures.travelSegment(from: "New York", to: "Boston") let validOption = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [segment], totalDrivingHours: 3.5, totalDistanceMiles: 215, geographicRationale: "test" ) #expect(validOption.isValid, "2 stops with 1 segment should be valid") } @Test("planTrip: inverted date range returns failure") func planTrip_invertedDateRange_returnsFailure() { let endDate = TestFixtures.date(year: 2026, month: 6, day: 1) let startDate = TestFixtures.date(year: 2026, month: 6, day: 10) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: startDate, endDate: endDate ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) #expect(!result.isSuccess) if let failure = result.failure { #expect(failure.reason == .missingDateRange) } } // MARK: - Helper Methods private func makeStadium( id: String, city: String, coordinate: CLLocationCoordinate2D ) -> Stadium { Stadium( id: id, name: "\(city) Stadium", city: city, state: "XX", latitude: coordinate.latitude, longitude: coordinate.longitude, capacity: 40000, sport: .mlb ) } private func makeGame( id: String, stadiumId: String, dateTime: Date ) -> Game { Game( id: id, homeTeamId: "team1", awayTeamId: "team2", stadiumId: stadiumId, dateTime: dateTime, sport: .mlb, season: "2026" ) } }