// // TravelSegmentIntegrityTests.swift // SportsTimeTests // // CRITICAL INVARIANT: Every itinerary option returned to users MUST have // valid travel segments between ALL consecutive stops. // // N stops → exactly N-1 travel segments. No exceptions. // // This file tests the invariant at every layer: // 1. ItineraryBuilder.build() — the segment factory // 2. ItineraryOption.isValid — the runtime check // 3. TripPlanningEngine — the final gate // 4. Each scenario planner (A-E) — end-to-end // 5. Edge cases — single stops, same-city, missing coords, cross-country // import Testing import Foundation import CoreLocation @testable import SportsTime // MARK: - Layer 1: ItineraryBuilder Invariant @Suite("Travel Integrity: ItineraryBuilder") struct TravelIntegrity_BuilderTests { @Test("build: 2 stops → exactly 1 segment") func build_twoStops_oneSegment() { let nyc = TestFixtures.coordinates["New York"]! let boston = TestFixtures.coordinates["Boston"]! let stops = [ makeStop(city: "New York", coord: nyc, day: 0), makeStop(city: "Boston", coord: boston, day: 1) ] let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil, "NYC→Boston should build") #expect(result!.travelSegments.count == 1, "2 stops must have exactly 1 segment") #expect(result!.travelSegments[0].estimatedDistanceMiles > 0, "Segment must have distance") #expect(result!.travelSegments[0].estimatedDrivingHours > 0, "Segment must have duration") } @Test("build: 3 stops → exactly 2 segments") func build_threeStops_twoSegments() { let stops = [ makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0), makeStop(city: "Philadelphia", coord: TestFixtures.coordinates["Philadelphia"]!, day: 1), makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2) ] let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil) #expect(result!.travelSegments.count == 2, "3 stops must have exactly 2 segments") } @Test("build: 5 stops → exactly 4 segments") func build_fiveStops_fourSegments() { let cities = ["New York", "Philadelphia", "Boston", "Chicago", "Detroit"] let stops = cities.enumerated().map { i, city in makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i) } let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil) #expect(result!.travelSegments.count == 4, "5 stops must have exactly 4 segments") } @Test("build: single stop → 0 segments") func build_singleStop_noSegments() { let stops = [makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0)] let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil) #expect(result!.travelSegments.isEmpty, "1 stop must have 0 segments") } @Test("build: empty stops → 0 segments") func build_emptyStops_noSegments() { let result = ItineraryBuilder.build(stops: [], constraints: .default) #expect(result != nil) #expect(result!.travelSegments.isEmpty) #expect(result!.stops.isEmpty) } @Test("build: missing coordinates → returns nil (not partial)") func build_missingCoords_returnsNil() { let stops = [ makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0), makeStop(city: "Atlantis", coord: nil, day: 1), // No coords! makeStop(city: "Boston", coord: TestFixtures.coordinates["Boston"]!, day: 2) ] let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result == nil, "Missing coordinates must reject entire itinerary, not produce partial") } @Test("build: infeasible segment → returns nil (not partial)") func build_infeasibleSegment_returnsNil() { // Use extremely tight constraints to make cross-country infeasible let tightConstraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 1.0) let stops = [ makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0), makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 1) ] // NYC→LA is ~2,800 miles. With 1 hour/day max, exceeds 5x limit (5 hours) let result = ItineraryBuilder.build(stops: stops, constraints: tightConstraints) #expect(result == nil, "Infeasible segment must reject entire itinerary") } @Test("build: every segment connects the correct stops in order") func build_segmentOrder_matchesStops() { let cities = ["New York", "Philadelphia", "Boston"] let stops = cities.enumerated().map { i, city in makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i) } let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil) // Verify segment endpoints match stop pairs for i in 0.. 1 { #expect(option.travelSegments.count == option.stops.count - 1, "Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)") } } } @Test("Engine rejects all-invalid options with segmentMismatch failure") func engine_rejectsAllInvalid() { // This tests the isValid filter in applyPreferenceFilters // We can't easily inject invalid options, but we verify the code path exists let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! ) // No games → should fail (not return empty success) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) #expect(!result.isSuccess, "No games should produce failure, not empty success") } } // MARK: - Layer 4: End-to-End Scenario Tests @Suite("Travel Integrity: Scenario A (Date Range)") struct TravelIntegrity_ScenarioATests { @Test("ScenarioA: all returned options have N-1 segments") func scenarioA_allOptionsValid() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let games = ["New York", "Boston", "Philadelphia"].enumerated().map { i, city in TestFixtures.game( city: city, dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)! ) } let stadiums = TestFixtures.stadiumMap(for: games) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! ) let request = PlanningRequest(preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) assertAllOptionsHaveValidTravel(result, scenario: "A") } } @Suite("Travel Integrity: Scenario B (Selected Games)") struct TravelIntegrity_ScenarioBTests { @Test("ScenarioB: all returned options have N-1 segments") func scenarioB_allOptionsValid() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let game1 = TestFixtures.game(id: "must_see_1", city: "New York", dateTime: baseDate) let game2 = TestFixtures.game( id: "must_see_2", city: "Boston", dateTime: TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! ) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let prefs = TripPreferences( planningMode: .gameFirst, sports: [.mlb], mustSeeGameIds: ["must_see_1", "must_see_2"], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) assertAllOptionsHaveValidTravel(result, scenario: "B") } } @Suite("Travel Integrity: Scenario C (Start/End Locations)") struct TravelIntegrity_ScenarioCTests { @Test("ScenarioC: all returned options have N-1 segments including endpoint stops") func scenarioC_allOptionsValid() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let chicagoCoord = TestFixtures.coordinates["Chicago"]! let nycCoord = TestFixtures.coordinates["New York"]! // Games along the Chicago → NYC route let game1 = TestFixtures.game( city: "Detroit", dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! ) let game2 = TestFixtures.game( city: "Philadelphia", dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)! ) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let prefs = TripPreferences( planningMode: .locations, startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord), endLocation: LocationInput(name: "New York", coordinate: nycCoord), sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) assertAllOptionsHaveValidTravel(result, scenario: "C") } } @Suite("Travel Integrity: Scenario D (Follow Team)") struct TravelIntegrity_ScenarioDTests { @Test("ScenarioD: all returned options have N-1 segments") func scenarioD_allOptionsValid() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let teamId = "team_mlb_new_york" let game1 = TestFixtures.game( city: "New York", dateTime: baseDate, homeTeamId: teamId, stadiumId: "stadium_mlb_new_york" ) let game2 = TestFixtures.game( city: "Boston", dateTime: TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!, homeTeamId: "team_mlb_boston", awayTeamId: teamId, stadiumId: "stadium_mlb_boston" ) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let team = TestFixtures.team(id: teamId, name: "Yankees", sport: .mlb, city: "New York") let prefs = TripPreferences( planningMode: .followTeam, sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!, followTeamId: teamId ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [teamId: team], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) assertAllOptionsHaveValidTravel(result, scenario: "D") } } @Suite("Travel Integrity: Scenario E (Team-First)") struct TravelIntegrity_ScenarioETests { @Test("ScenarioE: all returned options have N-1 segments") func scenarioE_allOptionsValid() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let nycTeam = TestFixtures.team(id: "team_nyc", name: "NYC Team", sport: .mlb, city: "New York") let bosTeam = TestFixtures.team(id: "team_bos", name: "BOS Team", sport: .mlb, city: "Boston") // Create home games for each team let nycGames = (0..<3).map { i in TestFixtures.game( id: "nyc_\(i)", city: "New York", dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2, to: baseDate)!, homeTeamId: "team_nyc", stadiumId: "stadium_mlb_new_york" ) } let bosGames = (0..<3).map { i in TestFixtures.game( id: "bos_\(i)", city: "Boston", dateTime: TestClock.calendar.date(byAdding: .day, value: i * 2 + 1, to: baseDate)!, homeTeamId: "team_bos", stadiumId: "stadium_mlb_boston" ) } let allGames = nycGames + bosGames let stadiums = TestFixtures.stadiumMap(for: allGames) let teams: [String: Team] = ["team_nyc": nycTeam, "team_bos": bosTeam] let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedTeamIds: ["team_nyc", "team_bos"] ) let request = PlanningRequest( preferences: prefs, availableGames: allGames, teams: teams, stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) assertAllOptionsHaveValidTravel(result, scenario: "E") } } // MARK: - Layer 5: Edge Cases @Suite("Travel Integrity: Edge Cases") struct TravelIntegrity_EdgeCaseTests { @Test("Same-city consecutive stops have zero-distance segment") func sameCityStops_haveZeroDistanceSegment() { let coord = TestFixtures.coordinates["New York"]! let stops = [ makeStop(city: "New York", coord: coord, day: 0), makeStop(city: "New York", coord: coord, day: 1) ] let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil, "Same-city stops should build") #expect(result!.travelSegments.count == 1, "Must still have segment") // Distance should be very small (same coords) } @Test("Cross-country trip (NYC→LA) rejected with 1 driver, feasible with 2") func crossCountry_feasibilityDependsOnDrivers() { let stops = [ makeStop(city: "New York", coord: TestFixtures.coordinates["New York"]!, day: 0), makeStop(city: "Los Angeles", coord: TestFixtures.coordinates["Los Angeles"]!, day: 5) ] // 1 driver, 8 hrs/day → max 40 hrs (5x limit). NYC→LA is ~53 hrs → infeasible let oneDriver = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(oneDriver == nil, "NYC→LA exceeds 5x daily limit for 1 driver") // 2 drivers, 8 hrs each → 16 hrs/day → max 80 hrs → feasible let twoDriverConstraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) let twoDrivers = ItineraryBuilder.build(stops: stops, constraints: twoDriverConstraints) #expect(twoDrivers != nil, "NYC→LA should build with 2 drivers") if let built = twoDrivers { #expect(built.travelSegments.count == 1) #expect(built.travelSegments[0].estimatedDistanceMiles > 2000, "NYC→LA should be 2000+ miles") } } @Test("Multi-stop trip never has mismatched segment count") func multiStopTrip_neverMismatched() { // Property test: for any number of stops 2-10, segments == stops - 1 let allCities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit", "Atlanta", "Miami", "Houston", "Denver", "Minneapolis"] for stopCount in 2...min(10, allCities.count) { let cities = Array(allCities.prefix(stopCount)) let stops = cities.enumerated().map { i, city in makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i) } let result = ItineraryBuilder.build(stops: stops, constraints: .default) if let built = result { #expect(built.travelSegments.count == stopCount - 1, "\(stopCount) stops must produce \(stopCount - 1) segments, got \(built.travelSegments.count)") } // nil is acceptable (infeasible), but never partial } } @Test("Every travel segment has positive distance when cities differ") func everySegment_hasPositiveDistance() { let cities = ["New York", "Boston", "Philadelphia", "Chicago"] let stops = cities.enumerated().map { i, city in makeStop(city: city, coord: TestFixtures.coordinates[city]!, day: i) } let result = ItineraryBuilder.build(stops: stops, constraints: .default) #expect(result != nil) for (i, segment) in result!.travelSegments.enumerated() { #expect(segment.estimatedDistanceMiles > 0, "Segment \(i) (\(segment.fromLocation.name)→\(segment.toLocation.name)) must have positive distance") #expect(segment.estimatedDrivingHours > 0, "Segment \(i) must have positive driving hours") } } @Test("Segment from/to locations match adjacent stops") func segmentEndpoints_matchAdjacentStops() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let cities = ["New York", "Boston", "Philadelphia"] let games = cities.enumerated().map { i, city in TestFixtures.game( city: city, dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)! ) } let stadiums = TestFixtures.stadiumMap(for: games) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! ) 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 } for option in options { for i in 0.. 1 { #expect(option.travelSegments.count == option.stops.count - 1, "Scenario \(scenario) option \(i): segment count mismatch", sourceLocation: sourceLocation) // Every segment must have non-negative distance for (j, seg) in option.travelSegments.enumerated() { #expect(seg.estimatedDistanceMiles >= 0, "Scenario \(scenario) option \(i) segment \(j): negative distance", sourceLocation: sourceLocation) } } } } /// Helper to create a basic ItineraryStop for testing. private func makeStop( city: String, coord: CLLocationCoordinate2D?, day: Int ) -> ItineraryStop { let date = TestClock.addingDays(day) return ItineraryStop( city: city, state: TestFixtures.states[city] ?? "", coordinate: coord, games: ["game_\(city.lowercased())_\(day)"], arrivalDate: date, departureDate: date, location: LocationInput(name: city, coordinate: coord), firstGameStart: date ) }