// // ImprovementPlanTDDTests.swift // SportsTimeTests // // TDD-driven verification of all 4 improvement plan phases. // Each test is written to verify expected behavior — RED if missing, GREEN if implemented. // import Testing import Foundation import CoreLocation @testable import SportsTime // MARK: - Phase 1A: Route Preference wired into GameDAGRouter @Suite("Phase 1A: Route Preference in GameDAGRouter") struct Phase1A_RoutePreferenceTests { private let constraints = DrivingConstraints.default @Test("Direct preference prioritizes low-mileage routes in final selection") func directPreference_prioritizesLowMileage() { // Create games spread across East Coast (short) and cross-country (long) let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)! // Close games (East Coast corridor) let nyc = TestFixtures.game(id: "nyc1", city: "New York", dateTime: baseDate) let bos = TestFixtures.game(id: "bos1", city: "Boston", dateTime: day2) let phi = TestFixtures.game(id: "phi1", city: "Philadelphia", dateTime: day3) // Far game (West Coast) let la = TestFixtures.game(id: "la1", city: "Los Angeles", dateTime: day4) let stadiums = TestFixtures.stadiumMap(for: [nyc, bos, phi, la]) let directRoutes = GameDAGRouter.findRoutes( games: [nyc, bos, phi, la], stadiums: stadiums, constraints: constraints, routePreference: .direct ) let scenicRoutes = GameDAGRouter.findRoutes( games: [nyc, bos, phi, la], stadiums: stadiums, constraints: constraints, routePreference: .scenic ) // Both should produce routes #expect(!directRoutes.isEmpty, "Direct should produce routes") #expect(!scenicRoutes.isEmpty, "Scenic should produce routes") // Route preference is used for ordering within diversity selection // Verify the parameter is accepted and produces valid output for route in directRoutes { #expect(route.count >= 1, "Each route should have at least 1 game") // Games should be chronologically ordered for i in 0..<(route.count - 1) { #expect(route[i].startTime <= route[i + 1].startTime) } } } @Test("findRoutes accepts routePreference parameter for all values") func findRoutes_acceptsAllRoutePreferences() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "Boston", dateTime: day2) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) for pref in RoutePreference.allCases { let routes = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: stadiums, constraints: constraints, routePreference: pref ) #expect(!routes.isEmpty, "\(pref) should produce routes") } } @Test("selectDiverseRoutes uses routePreference for bucket ordering") func selectDiverseRoutes_usesRoutePreference() { // This test verifies that the route preference enum has the expected scenic weights #expect(RoutePreference.direct.scenicWeight == 0.0) #expect(RoutePreference.scenic.scenicWeight == 1.0) #expect(RoutePreference.balanced.scenicWeight == 0.5) } } // MARK: - Phase 1B: Region Filter in ScenarioE @Suite("Phase 1B: ScenarioE Region Filtering") struct Phase1B_ScenarioERegionTests { @Test("ScenarioE filters games by selectedRegions") func scenarioE_filtersGamesByRegion() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) // Create teams in East and West let nycTeam = TestFixtures.team(id: "team_nyc", name: "NYC Team", sport: .mlb, city: "New York") let laTeam = TestFixtures.team(id: "team_la", name: "LA Team", sport: .mlb, city: "Los Angeles") // Home games: NYC (East, lon -73.9855), LA (West, lon -118.2400) let nycGames = (0..<5).map { i in TestFixtures.game( id: "nyc_\(i)", city: "New York", dateTime: TestClock.calendar.date(byAdding: .day, value: i * 3, to: baseDate)!, homeTeamId: "team_nyc", stadiumId: "stadium_mlb_new_york" ) } let laGames = (0..<5).map { i in TestFixtures.game( id: "la_\(i)", city: "Los Angeles", dateTime: TestClock.calendar.date(byAdding: .day, value: i * 3 + 1, to: baseDate)!, homeTeamId: "team_la", stadiumId: "stadium_mlb_los_angeles" ) } let allGames = nycGames + laGames let stadiums = TestFixtures.stadiumMap(for: allGames) let teams: [String: Team] = ["team_nyc": nycTeam, "team_la": laTeam] // Only East region selected — should filter out LA games let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedRegions: [.east], selectedTeamIds: ["team_nyc", "team_la"] ) let request = PlanningRequest( preferences: prefs, availableGames: allGames, teams: teams, stadiums: stadiums ) let planner = ScenarioEPlanner() let result = planner.plan(request: request) // With only East region, LA team has no home games → should fail guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)") return } #expect(failure.reason == .noGamesInRange, "Should fail because LA team has no East region games") } @Test("ScenarioE with all regions includes all teams") func scenarioE_allRegions_includesAll() { 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") let game1 = TestFixtures.game( id: "nyc_home", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_mlb_new_york" ) let game2 = TestFixtures.game( id: "bos_home", city: "Boston", dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!, homeTeamId: "team_bos", stadiumId: "stadium_mlb_boston" ) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let teams: [String: Team] = ["team_nyc": nycTeam, "team_bos": bosTeam] let prefs = TripPreferences( planningMode: .teamFirst, sports: [.mlb], selectedRegions: [.east, .central, .west], selectedTeamIds: ["team_nyc", "team_bos"] ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: teams, stadiums: stadiums ) let planner = ScenarioEPlanner() let result = planner.plan(request: request) // Should succeed with both nearby East Coast teams. // Failure is also OK if driving constraints prevent it. switch result { case .success(let options): #expect(!options.isEmpty, "Should find routes for NYC + Boston") case .failure: break // Acceptable — driving constraints may prevent a valid route } } } // MARK: - Phase 1C: Must-Stop All Scenarios (verification) @Suite("Phase 1C: Must-Stop Centralized Verification") struct Phase1C_MustStopTests { @Test("Must-stop filtering is in TripPlanningEngine.applyPreferenceFilters") func mustStop_centralizedInEngine() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "Boston", dateTime: day2) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) // Require Boston as must-stop let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day2, mustStopLocations: [LocationInput(name: "Boston")] ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // If successful, all options must include Boston guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let cities = option.stops.map { $0.city.lowercased() } #expect(cities.contains("boston"), "All options must include Boston must-stop") } } } // MARK: - Phase 1D: Travel Segment Validation @Suite("Phase 1D: Travel Segment Validation") struct Phase1D_TravelSegmentTests { @Test("ItineraryOption.isValid checks N-1 segments for N stops") func isValid_checksSegmentCount() { let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: TestFixtures.coordinates["New York"], games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York"), firstGameStart: nil ) let stop2 = ItineraryStop( city: "Boston", state: "MA", coordinate: TestFixtures.coordinates["Boston"], games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(1), location: LocationInput(name: "Boston"), firstGameStart: nil ) let segment = TestFixtures.travelSegment(from: "New York", to: "Boston") // Valid: 2 stops, 1 segment let validOption = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [segment], totalDrivingHours: 3.5, totalDistanceMiles: 215, geographicRationale: "test" ) #expect(validOption.isValid, "2 stops + 1 segment should be valid") // Invalid: 2 stops, 0 segments let invalidOption = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [], totalDrivingHours: 0, totalDistanceMiles: 0, geographicRationale: "test" ) #expect(!invalidOption.isValid, "2 stops + 0 segments should be invalid") // Valid: 1 stop, 0 segments let singleStop = ItineraryOption( rank: 1, stops: [stop1], travelSegments: [], totalDrivingHours: 0, totalDistanceMiles: 0, geographicRationale: "test" ) #expect(singleStop.isValid, "1 stop + 0 segments should be valid") } @Test("TripPlanningEngine filters out invalid options") func engine_filtersInvalidOptions() { // Engine's applyPreferenceFilters checks isValid let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "Boston", dateTime: day2) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day2 ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // If successful, all returned options must be valid guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { #expect(option.isValid, "Engine should only return valid options") } } } // MARK: - Phase 2A: TravelEstimator returns nil on missing coordinates @Suite("Phase 2A: TravelEstimator No Fallback Distance") struct Phase2A_NoFallbackDistanceTests { @Test("Missing coordinates returns nil, not fallback distance") func missingCoordinates_returnsNil() { let from = ItineraryStop( city: "New York", state: "NY", coordinate: nil, games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York"), firstGameStart: nil ) let to = ItineraryStop( city: "Chicago", state: "IL", coordinate: nil, games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(1), location: LocationInput(name: "Chicago"), firstGameStart: nil ) let result = TravelEstimator.estimate( from: from, to: to, constraints: .default ) #expect(result == nil, "Missing coordinates should return nil, not a fallback distance") } @Test("Same city with no coords returns zero-distance segment") func sameCity_noCoords_returnsZero() { let from = ItineraryStop( city: "New York", state: "NY", coordinate: nil, games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York"), firstGameStart: nil ) let to = ItineraryStop( city: "New York", state: "NY", coordinate: nil, games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(1), location: LocationInput(name: "New York"), firstGameStart: nil ) let result = TravelEstimator.estimate( from: from, to: to, constraints: .default ) #expect(result != nil, "Same city should return a segment") #expect(result?.distanceMeters == 0, "Same city distance should be 0") } @Test("Valid coordinates returns distance based on Haversine formula") func validCoordinates_returnsHaversineDistance() { let nyc = TestFixtures.coordinates["New York"]! let boston = TestFixtures.coordinates["Boston"]! let from = ItineraryStop( city: "New York", state: "NY", coordinate: nyc, games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York", coordinate: nyc), firstGameStart: nil ) let to = ItineraryStop( city: "Boston", state: "MA", coordinate: boston, games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(1), location: LocationInput(name: "Boston", coordinate: boston), firstGameStart: nil ) let result = TravelEstimator.estimate(from: from, to: to, constraints: .default) #expect(result != nil, "Valid coordinates should produce a segment") // NYC to Boston road distance ~250 miles (haversine ~190 * 1.3 routing factor) let miles = result!.estimatedDistanceMiles #expect(miles > 200 && miles < 350, "NYC→Boston should be 200-350 miles, got \(miles)") } } // MARK: - Phase 2B: Same-Stadium Gap @Suite("Phase 2B: Same-Stadium Gap Check") struct Phase2B_SameStadiumGapTests { @Test("Same stadium games with sufficient gap are feasible") func sameStadium_sufficientGap_feasible() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 13) // Game 2 is 5 hours later (3h game + 1h gap + 1h spare) let game2Date = TestClock.calendar.date(byAdding: .hour, value: 5, to: baseDate)! let game1 = TestFixtures.game( id: "g1", city: "New York", dateTime: baseDate, stadiumId: "shared_stadium" ) let game2 = TestFixtures.game( id: "g2", city: "New York", dateTime: game2Date, stadiumId: "shared_stadium" ) let stadium = TestFixtures.stadium(id: "shared_stadium", city: "New York", sport: .mlb) let stadiums = [stadium.id: stadium] let routes = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: stadiums, constraints: .default ) // Should find a route with both games (5h gap > 3h game + 1h min gap) let combined = routes.first(where: { $0.count == 2 }) #expect(combined != nil, "5-hour gap at same stadium should be feasible") } @Test("Same stadium games with insufficient gap are infeasible together") func sameStadium_insufficientGap_infeasible() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 13) // Game 2 is only 2 hours later (< 3h game + 1h min gap) let game2Date = TestClock.calendar.date(byAdding: .hour, value: 2, to: baseDate)! let game1 = TestFixtures.game( id: "g1", city: "New York", dateTime: baseDate, stadiumId: "shared_stadium" ) let game2 = TestFixtures.game( id: "g2", city: "New York", dateTime: game2Date, stadiumId: "shared_stadium" ) let stadium = TestFixtures.stadium(id: "shared_stadium", city: "New York", sport: .mlb) let stadiums = [stadium.id: stadium] let routes = GameDAGRouter.findRoutes( games: [game1, game2], stadiums: stadiums, constraints: .default ) // Should NOT have a combined route (2h gap < 3h game + 1h min gap = 4h needed) let combined = routes.first(where: { $0.count == 2 }) #expect(combined == nil, "2-hour gap at same stadium should be infeasible") } } // MARK: - Phase 2C: Overnight Rest in Timeline @Suite("Phase 2C: Overnight Rest & RestDay") struct Phase2C_OvernightRestTests { @Test("requiresOvernightStop returns true when driving exceeds daily limit") func requiresOvernight_exceedsLimit() { let longSegment = TestFixtures.travelSegment(from: "New York", to: "Chicago") let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let needsOvernight = TravelEstimator.requiresOvernightStop( segment: longSegment, constraints: constraints ) #expect(needsOvernight, "NYC→Chicago (~13h) should require overnight with 8h limit") } @Test("requiresOvernightStop returns false for short segments") func requiresOvernight_shortSegment() { let shortSegment = TestFixtures.travelSegment(from: "New York", to: "Boston") let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let needsOvernight = TravelEstimator.requiresOvernightStop( segment: shortSegment, constraints: constraints ) #expect(!needsOvernight, "NYC→Boston (~4h) should not require overnight") } @Test("generateTimeline inserts overnight rest days for long segments") func generateTimeline_insertsOvernightRest() { let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: TestFixtures.coordinates["New York"], games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), firstGameStart: TestClock.now ) let stop2 = ItineraryStop( city: "Los Angeles", state: "CA", coordinate: TestFixtures.coordinates["Los Angeles"], games: ["g2"], arrivalDate: TestClock.addingDays(4), departureDate: TestClock.addingDays(4), location: LocationInput(name: "Los Angeles", coordinate: TestFixtures.coordinates["Los Angeles"]), firstGameStart: TestClock.addingDays(4) ) // Long segment: NYC→LA ~2,800 miles, ~46h driving let longSegment = TestFixtures.travelSegment(from: "New York", to: "Los Angeles") let option = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [longSegment], totalDrivingHours: longSegment.estimatedDrivingHours, totalDistanceMiles: longSegment.estimatedDistanceMiles, geographicRationale: "cross-country" ) let timeline = option.generateTimeline() let restItems = timeline.filter { $0.isRest } #expect(!restItems.isEmpty, "Cross-country trip should have overnight rest days in timeline") } @Test("calculateTravelDays returns multiple days for long drives") func calculateTravelDays_longDrive() { let days = TravelEstimator.calculateTravelDays( departure: TestClock.now, drivingHours: 20.0, drivingConstraints: DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) ) #expect(days.count == 3, "20h driving / 8h per day = 3 days") } } // MARK: - Phase 2D: Silent Exclusion Warning @Suite("Phase 2D: Silent Exclusion Warnings") struct Phase2D_ExclusionWarningTests { @Test("Engine filters repeat-city routes when allowRepeatCities is false") func engine_tracksRepeatCityWarnings() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) 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: "New York", dateTime: day3) let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3]) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day3, allowRepeatCities: false ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // With allowRepeatCities=false, engine should return only routes without repeat cities guard case .success(let options) = result else { // If all routes had repeat cities, failure is also acceptable return } // Every returned route must have unique cities per calendar day for option in options { let calendar = Calendar.current var cityDays: Set = [] for stop in option.stops { let day = calendar.startOfDay(for: stop.arrivalDate) let key = "\(stop.city.lowercased())_\(day.timeIntervalSince1970)" #expect(!cityDays.contains(key), "Route should not visit \(stop.city) on the same day twice") cityDays.insert(key) } } } @Test("Engine tracks must-stop exclusion warnings") func engine_tracksMustStopWarnings() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "Boston", dateTime: day2) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day2, mustStopLocations: [LocationInput(name: "Atlantis")] ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // Should fail with must-stop violation guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)") return } let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop }) #expect(hasMustStopViolation, "Failure should include mustStop constraint violation") } @Test("Engine tracks segment validation warnings") func engine_tracksSegmentWarnings() { let engine = TripPlanningEngine() // Warnings array should be resettable between planning runs 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: 7, to: baseDate)! ) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) _ = engine.planItineraries(request: request) let warningsAfterFirst = engine.warnings // After a second run, warnings should be reset (not accumulated) _ = engine.planItineraries(request: request) let warningsAfterSecond = engine.warnings // Warnings from first run should not leak into second run #expect(warningsAfterSecond.count == warningsAfterFirst.count, "Warnings should be reset between runs, not accumulated (\(warningsAfterFirst.count) vs \(warningsAfterSecond.count))") } } // MARK: - Phase 3A-3E: Hardening Tests (verification that existing tests cover all areas) @Suite("Phase 3: Hardening Verification") struct Phase3_HardeningVerificationTests { @Test("3A: Region.classify correctly classifies by longitude") func regionClassify_correctLongitudes() { // East: > -85 #expect(Region.classify(longitude: -73.9855) == .east, "NYC should be East") #expect(Region.classify(longitude: -80.2197) == .east, "Miami should be East") // Central: -110 to -85 #expect(Region.classify(longitude: -87.6553) == .central, "Chicago should be Central") #expect(Region.classify(longitude: -104.9942) == .central, "Denver should be Central") // West: < -110 #expect(Region.classify(longitude: -118.2400) == .west, "LA should be West") #expect(Region.classify(longitude: -122.3893) == .west, "SF should be West") } @Test("3B: DrivingConstraints default values are correct") func drivingConstraints_defaults() { let defaults = DrivingConstraints.default #expect(defaults.numberOfDrivers == 1) #expect(defaults.maxHoursPerDriverPerDay == 8.0) #expect(defaults.maxDailyDrivingHours == 8.0) } @Test("3C: Empty games returns appropriate failure") func emptyGames_returnsFailure() { 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: 7, to: baseDate)! ) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) #expect(!result.isSuccess, "No games should produce a failure") } @Test("3D: ItineraryBuilder verifies N-1 segment invariant") func itineraryBuilder_verifiesSegmentInvariant() { let nyc = TestFixtures.coordinates["New York"]! let boston = TestFixtures.coordinates["Boston"]! let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: nyc, games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York", coordinate: nyc), firstGameStart: nil ) let stop2 = ItineraryStop( city: "Boston", state: "MA", coordinate: boston, games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(1), location: LocationInput(name: "Boston", coordinate: boston), firstGameStart: nil ) let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: .default) #expect(result != nil, "NYC→Boston should build successfully") #expect(result?.travelSegments.count == 1, "2 stops should produce 1 segment") } @Test("3E: Multiple constraints don't conflict silently") func multipleConstraints_noSilentConflicts() { // Verify that planning with multiple constraints either succeeds cleanly // or fails with an explicit reason let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day7 = TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day7, mustStopLocations: [LocationInput(name: "Boston")], allowRepeatCities: false, selectedRegions: [.east] ) let games = (0..<5).map { i in let city = ["New York", "Boston", "Philadelphia", "Miami", "Atlanta"][i] return TestFixtures.game( city: city, dateTime: TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)! ) } let stadiums = TestFixtures.stadiumMap(for: games) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // Result should be either success or an explicit failure — never a crash or empty success switch result { case .success(let options): #expect(!options.isEmpty, "Success should have at least one option") case .failure(let failure): #expect(!failure.message.isEmpty, "Failure should have a meaningful message") } } } // MARK: - Phase 4A: Re-filter on preference toggle post-planning @Suite("Phase 4A: Post-Planning Re-sort by Route Preference") struct Phase4A_RefilterTests { @Test("sortByRoutePreference re-sorts options without re-planning") func sortByRoutePreference_resortsOptions() { let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: TestFixtures.coordinates["New York"], games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York"), firstGameStart: nil ) // High mileage option let highMileOption = ItineraryOption( rank: 1, stops: [stop1], travelSegments: [], totalDrivingHours: 20, totalDistanceMiles: 1200, geographicRationale: "scenic cross-country" ) // Low mileage option let lowMileOption = ItineraryOption( rank: 2, stops: [stop1], travelSegments: [], totalDrivingHours: 3, totalDistanceMiles: 180, geographicRationale: "quick east coast" ) let options = [highMileOption, lowMileOption] // Direct: should prefer lower mileage let directSorted = ItineraryOption.sortByRoutePreference(options, routePreference: .direct) #expect(directSorted.first?.totalDistanceMiles == 180, "Direct should put low-mileage first") // Scenic: should prefer higher mileage (more exploration) let scenicSorted = ItineraryOption.sortByRoutePreference(options, routePreference: .scenic) #expect(scenicSorted.first?.totalDistanceMiles == 1200, "Scenic should put high-mileage first") } @Test("sortByRoutePreference preserves all options") func sortByRoutePreference_preservesAll() { let stop = ItineraryStop( city: "NYC", state: "NY", coordinate: nil, games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "NYC"), firstGameStart: nil ) let options = (0..<5).map { i in ItineraryOption( rank: i + 1, stops: [stop], travelSegments: [], totalDrivingHours: Double(i * 3), totalDistanceMiles: Double(i * 200), geographicRationale: "option \(i)" ) } for pref in RoutePreference.allCases { let sorted = ItineraryOption.sortByRoutePreference(options, routePreference: pref) #expect(sorted.count == options.count, "\(pref) should preserve all options") // Ranks should be reassigned sequentially for (i, opt) in sorted.enumerated() { #expect(opt.rank == i + 1, "Rank should be \(i + 1)") } } } } // MARK: - Phase 4B: Reject Inverted Date Ranges @Suite("Phase 4B: Inverted Date Range Rejection") struct Phase4B_InvertedDateRangeTests { @Test("Inverted date range returns missingDateRange failure") func invertedDateRange_returnsMissingDateRange() { let later = TestFixtures.date(year: 2026, month: 6, day: 15, hour: 12) let earlier = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: later, // Start AFTER end endDate: earlier ) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)") return } #expect(failure.reason == .missingDateRange, "Inverted date range should return missingDateRange failure") #expect(failure.violations.contains(where: { $0.type == .dateRange }), "Should include dateRange violation") } } // MARK: - Phase 4C: Warn on Empty Sports Set @Suite("Phase 4C: Empty Sports Set Warning") struct Phase4C_EmptySportsTests { @Test("Empty sports set produces missingData warning") func emptySports_producesMissingDataWarning() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let prefs = TripPreferences( planningMode: .dateRange, sports: [], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! ) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) let engine = TripPlanningEngine() _ = engine.planItineraries(request: request) let hasMissingDataWarning = engine.warnings.contains(where: { $0.type == .missingData }) #expect(hasMissingDataWarning, "Empty sports should produce missingData warning") } } // MARK: - Phase 4D: Cross-Country Games in Per-Region Searches @Suite("Phase 4D: Cross-Country Games in Region Searches") struct Phase4D_CrossCountryTests { @Test("Region.classify covers all longitude ranges") func regionClassify_coversAllRanges() { // Boundary tests #expect(Region.classify(longitude: -84.9) == .east, "Just above -85 should be East") #expect(Region.classify(longitude: -85.0) == .central, "-85 should be Central") #expect(Region.classify(longitude: -110.0) == .central, "-110 should be Central") #expect(Region.classify(longitude: -110.1) == .west, "Just below -110 should be West") } @Test("crossCountry region exists as enum case") func crossCountry_existsAsCase() { let crossCountry = Region.crossCountry #expect(crossCountry.displayName == "Cross-Country") } @Test("Games at West Coast stadiums are classified as West") func westCoastStadiums_classifiedAsWest() { // LA stadium longitude = -118.2400 let region = Region.classify(longitude: -118.2400) #expect(region == .west, "LA should be classified as West") // Seattle longitude = -122.3325 let seattleRegion = Region.classify(longitude: -122.3325) #expect(seattleRegion == .west, "Seattle should be classified as West") } @Test("Per-region search only includes games at stadiums in that region") func perRegionSearch_onlyIncludesRegionStadiums() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) // NYC game (East) and LA game (West) let nycGame = TestFixtures.game(city: "New York", dateTime: baseDate) let laGame = TestFixtures.game( city: "Los Angeles", dateTime: TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! ) let stadiums = TestFixtures.stadiumMap(for: [nycGame, laGame]) // East-only search let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)!, selectedRegions: [.east] ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, laGame], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // If successful, should NOT contain LA guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let cities = option.stops.map { $0.city } #expect(!cities.contains("Los Angeles"), "East-only search should not include LA") } } }