// // PlanningHardeningTests.swift // SportsTimeTests // // Phase 3: Test hardening — timezone edge cases, driving constraint boundaries, // filter cascades, anchor constraints, and constraint interactions. // import Testing import Foundation import CoreLocation @testable import SportsTime // MARK: - 3A: Timezone Edge Cases @Suite("Timezone Edge Cases") struct TimezoneEdgeCaseTests { @Test("Game near midnight in Eastern shows on correct calendar day") func game_nearMidnight_eastern_correctDay() { // Use Eastern timezone calendar for consistent results regardless of machine timezone var etCalendar = Calendar.current etCalendar.timeZone = TimeZone(identifier: "America/New_York")! // 11:30 PM ET — should be on July 15, not July 16 var components = DateComponents() components.year = 2026 components.month = 7 components.day = 15 components.hour = 23 components.minute = 30 components.timeZone = TimeZone(identifier: "America/New_York") let lateGame = etCalendar.date(from: components)! let game = TestFixtures.game(city: "New York", dateTime: lateGame) let dayOfGame = etCalendar.startOfDay(for: game.startTime) var expectedComponents = DateComponents() expectedComponents.year = 2026 expectedComponents.month = 7 expectedComponents.day = 15 expectedComponents.timeZone = TimeZone(identifier: "America/New_York") let expectedDay = etCalendar.startOfDay(for: etCalendar.date(from: expectedComponents)!) #expect(dayOfGame == expectedDay, "Late-night game should be on the same calendar day in Eastern") } @Test("Cross-timezone travel: game times compared in UTC") func crossTimezone_gameTimesComparedInUTC() { let calendar = Calendar.current // Game 1: 7 PM ET in New York var comp1 = DateComponents() comp1.year = 2026; comp1.month = 7; comp1.day = 15 comp1.hour = 19; comp1.minute = 0 comp1.timeZone = TimeZone(identifier: "America/New_York") let game1Time = calendar.date(from: comp1)! // Game 2: 7 PM CT in Chicago (= 8 PM ET, 1 hour later) var comp2 = DateComponents() comp2.year = 2026; comp2.month = 7; comp2.day = 15 comp2.hour = 19; comp2.minute = 0 comp2.timeZone = TimeZone(identifier: "America/Chicago") let game2Time = calendar.date(from: comp2)! // Game 2 is AFTER game 1 in absolute time #expect(game2Time > game1Time, "Chicago 7PM should be after NYC 7PM in absolute time") let game1 = TestFixtures.game(city: "New York", dateTime: game1Time) let game2 = TestFixtures.game(city: "Chicago", dateTime: game2Time) #expect(game2.startTime > game1.startTime) } @Test("Day bucketing consistent across timezone boundaries") func dayBucketing_consistentAcrossTimezones() { // Use Eastern timezone calendar for consistent results var etCalendar = Calendar.current etCalendar.timeZone = TimeZone(identifier: "America/New_York")! // Two games: one at 11 PM ET, one at 12:30 AM ET next day var comp1 = DateComponents() comp1.year = 2026; comp1.month = 7; comp1.day = 15 comp1.hour = 23; comp1.minute = 0 comp1.timeZone = TimeZone(identifier: "America/New_York") let lateGame = etCalendar.date(from: comp1)! var comp2 = DateComponents() comp2.year = 2026; comp2.month = 7; comp2.day = 16 comp2.hour = 0; comp2.minute = 30 comp2.timeZone = TimeZone(identifier: "America/New_York") let earlyGame = etCalendar.date(from: comp2)! let day1 = etCalendar.startOfDay(for: lateGame) let day2 = etCalendar.startOfDay(for: earlyGame) #expect(day1 != day2, "Games across midnight should be on different calendar days") } } // MARK: - 3B: Driving Constraint Boundaries @Suite("Driving Constraint Boundaries") struct DrivingConstraintBoundaryTests { @Test("DrivingConstraints: exactly at max daily hours is feasible") func exactlyAtMaxDailyHours_isFeasible() { let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let nyc = TestFixtures.coordinates["New York"]! let boston = TestFixtures.coordinates["Boston"]! let from = ItineraryStop( city: "New York", state: "NY", coordinate: nyc, games: [], 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: [], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "Boston", coordinate: boston), firstGameStart: nil ) // NYC to Boston is ~250 road miles / 60 mph = ~4.2 hours, well under 8 let segment = TravelEstimator.estimate(from: from, to: to, constraints: constraints) #expect(segment != nil, "NYC to Boston should be feasible with 8-hour limit") } @Test("DrivingConstraints: minimum 1 driver always enforced") func minimumOneDriver_alwaysEnforced() { let constraints = DrivingConstraints(numberOfDrivers: 0, maxHoursPerDriverPerDay: 8.0) #expect(constraints.numberOfDrivers == 1) #expect(constraints.maxDailyDrivingHours == 8.0) } @Test("DrivingConstraints: minimum 1 hour per driver always enforced") func minimumOneHour_alwaysEnforced() { let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 0.0) #expect(constraints.maxHoursPerDriverPerDay == 1.0) #expect(constraints.maxDailyDrivingHours == 2.0) } @Test("DrivingConstraints: negative values clamped") func negativeValues_clamped() { let constraints = DrivingConstraints(numberOfDrivers: -3, maxHoursPerDriverPerDay: -10.0) #expect(constraints.numberOfDrivers >= 1) #expect(constraints.maxHoursPerDriverPerDay >= 1.0) #expect(constraints.maxDailyDrivingHours >= 1.0) } @Test("Overnight stop required for long segments") func overnightStop_requiredForLongSegments() { let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let shortSegment = TestFixtures.travelSegment(from: "New York", to: "Boston") let longSegment = TestFixtures.travelSegment(from: "New York", to: "Chicago") let shortNeedsOvernight = TravelEstimator.requiresOvernightStop( segment: shortSegment, constraints: constraints ) let longNeedsOvernight = TravelEstimator.requiresOvernightStop( segment: longSegment, constraints: constraints ) #expect(!shortNeedsOvernight, "NYC→Boston (~4h) should not need overnight") #expect(longNeedsOvernight, "NYC→Chicago (~13h) should need overnight") } } // MARK: - 3C: Filter Cascades @Suite("Filter Cascades") struct FilterCascadeTests { @Test("All options eliminated by repeat city filter → clear error") func allEliminatedByRepeatCity_clearsError() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let day2 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! // Both games in same city, different days → repeat city violation let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let game2 = TestFixtures.game(city: "New York", dateTime: day2) let stadiums = TestFixtures.stadiumMap(for: [game1, game2]) let prefs = TripPreferences( planningMode: .dateRange, sports: [.mlb], startDate: baseDate, endDate: day2, allowRepeatCities: false ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) if case .failure(let failure) = result { // Should get either repeatCityViolation or noGamesInRange/noValidRoutes let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"]) || failure.reason == .noValidRoutes || failure.reason == .noGamesInRange #expect(isExpectedFailure, "Should get a clear failure reason, got: \(failure.message)") } // Success is also acceptable if engine handles it differently } @Test("Must-stop filter with impossible city → clear error") func mustStopImpossibleCity_clearError() { 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) if case .failure(let failure) = result { #expect(failure.violations.contains(where: { $0.type == .mustStop }), "Should have mustStop violation") } // If no routes generated at all (noGamesInRange), that's also an acceptable failure } @Test("Empty sports set produces warning") func emptySportsSet_producesWarning() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let endDate = TestClock.calendar.date(byAdding: .day, value: 7, to: baseDate)! let prefs = TripPreferences( planningMode: .dateRange, sports: [], startDate: baseDate, endDate: endDate ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let engine = TripPlanningEngine() _ = engine.planItineraries(request: request) #expect(engine.warnings.contains(where: { $0.type == .missingData }), "Empty sports set should produce a warning") } @Test("Filters are idempotent: double-filtering produces same result") func filters_idempotent() { let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: TestFixtures.coordinates["New York"], games: ["g1"], arrivalDate: TestClock.now, departureDate: TestClock.addingDays(1), location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), firstGameStart: TestClock.now ) let stop2 = ItineraryStop( city: "Boston", state: "MA", coordinate: TestFixtures.coordinates["Boston"], games: ["g2"], arrivalDate: TestClock.addingDays(1), departureDate: TestClock.addingDays(2), location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), firstGameStart: TestClock.addingDays(1) ) let segment = TestFixtures.travelSegment(from: "New York", to: "Boston") let option = ItineraryOption( rank: 1, stops: [stop1, stop2], travelSegments: [segment], totalDrivingHours: 3.5, totalDistanceMiles: 215, geographicRationale: "test" ) let options = [option] let once = RouteFilters.filterRepeatCities(options, allow: false) let twice = RouteFilters.filterRepeatCities(once, allow: false) #expect(once.count == twice.count, "Filtering twice should produce same result as once") } } // MARK: - 3D: Anchor Constraints @Suite("Anchor Constraints") struct AnchorConstraintTests { @Test("Anchor game must appear in all returned routes") func anchorGame_appearsInAllRoutes() { 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(id: "anchor1", 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 constraints = DrivingConstraints.default let routes = GameDAGRouter.findRoutes( games: [game1, game2, game3], stadiums: stadiums, constraints: constraints, anchorGameIds: ["anchor1"] ) for route in routes { let routeGameIds = Set(route.map { $0.id }) #expect(routeGameIds.contains("anchor1"), "Every route must contain the anchor game") } } @Test("Unreachable anchor game produces empty routes") func unreachableAnchor_producesEmptyRoutes() { let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) // Only one game, but anchor references a non-existent game let game1 = TestFixtures.game(city: "New York", dateTime: baseDate) let stadiums = TestFixtures.stadiumMap(for: [game1]) let constraints = DrivingConstraints.default let routes = GameDAGRouter.findRoutes( games: [game1], stadiums: stadiums, constraints: constraints, anchorGameIds: ["nonexistent_anchor"] ) #expect(routes.isEmpty, "Non-existent anchor should produce no routes") } } // MARK: - 3E: Constraint Interactions @Suite("Constraint Interactions") struct ConstraintInteractionTests { @Test("Repeat city + must-stop interaction: must-stop in repeated city") func repeatCity_mustStop_interaction() { // Must-stop requires a city that would cause a repeat violation 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, mustStopLocations: [LocationInput(name: "New York")], allowRepeatCities: false ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2, game3], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // Engine should handle this gracefully — either find a route that visits NYC once // or return a clear failure if case .failure(let failure) = result { let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"]) || failure.reason == .noValidRoutes || failure.reason == .noGamesInRange #expect(hasReason, "Should fail with a clear reason") } // Success is fine too if engine finds a single-NYC-day route } @Test("Multiple drivers extend feasible distance") func multipleDrivers_extendFeasibleDistance() { let nyc = TestFixtures.coordinates["New York"]! let chicago = TestFixtures.coordinates["Chicago"]! let from = ItineraryStop( city: "New York", state: "NY", coordinate: nyc, games: [], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "New York", coordinate: nyc), firstGameStart: nil ) let to = ItineraryStop( city: "Chicago", state: "IL", coordinate: chicago, games: [], arrivalDate: TestClock.now, departureDate: TestClock.now, location: LocationInput(name: "Chicago", coordinate: chicago), firstGameStart: nil ) let oneDriver = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0) let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0) let segOne = TravelEstimator.estimate(from: from, to: to, constraints: oneDriver) let segTwo = TravelEstimator.estimate(from: from, to: to, constraints: twoDrivers) // Both should succeed (NYC→Chicago is ~13h driving, well within 40h/80h limits) #expect(segOne != nil && segTwo != nil) // But with 2 drivers, overnight requirement changes if let seg = segOne { let overnightOne = TravelEstimator.requiresOvernightStop(segment: seg, constraints: oneDriver) let overnightTwo = TravelEstimator.requiresOvernightStop(segment: seg, constraints: twoDrivers) #expect(overnightOne, "1 driver should need overnight for NYC→Chicago") #expect(!overnightTwo, "2 drivers should NOT need overnight for NYC→Chicago") } } @Test("Leisure level affects option ranking") func leisureLevel_affectsRanking() { let stop1 = ItineraryStop( city: "New York", state: "NY", coordinate: TestFixtures.coordinates["New York"], games: ["g1", "g2", "g3"], arrivalDate: TestClock.now, departureDate: TestClock.addingDays(1), location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), firstGameStart: TestClock.now ) let packedOption = ItineraryOption( rank: 1, stops: [stop1], travelSegments: [], totalDrivingHours: 10, totalDistanceMiles: 600, geographicRationale: "packed" ) let relaxedStop = ItineraryStop( city: "Boston", state: "MA", coordinate: TestFixtures.coordinates["Boston"], games: ["g4"], arrivalDate: TestClock.now, departureDate: TestClock.addingDays(1), location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), firstGameStart: TestClock.now ) let relaxedOption = ItineraryOption( rank: 2, stops: [relaxedStop], travelSegments: [], totalDrivingHours: 2, totalDistanceMiles: 100, geographicRationale: "relaxed" ) let options = [packedOption, relaxedOption] let packedSorted = ItineraryOption.sortByLeisure(options, leisureLevel: .packed) let relaxedSorted = ItineraryOption.sortByLeisure(options, leisureLevel: .relaxed) // Packed: more games first → packedOption should rank higher #expect(packedSorted.first?.totalGames == 3, "Packed should prioritize more games") // Relaxed: less driving first → relaxedOption should rank higher #expect(relaxedSorted.first?.totalDrivingHours == 2, "Relaxed should prioritize less driving") } @Test("Silent exclusion warnings are tracked") func silentExclusion_warningsTracked() { 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: "New York")] ) let request = PlanningRequest( preferences: prefs, availableGames: [game1, game2], teams: [:], stadiums: stadiums ) let engine = TripPlanningEngine() let result = engine.planItineraries(request: request) // Whether success or failure, warnings should be accessible // If options were filtered, we should see warnings if result.isSuccess && !engine.warnings.isEmpty { #expect(engine.warnings.allSatisfy { $0.severity == .warning }, "Exclusion notices should be warnings, not errors") } } }