Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests, and updates all scenario planner tests with improved coverage and assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
561 lines
23 KiB
Swift
561 lines
23 KiB
Swift
//
|
|
// 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)
|
|
|
|
switch result {
|
|
case .success:
|
|
break // Success is also acceptable — engine found a valid non-repeating route
|
|
case .failure(let failure):
|
|
// 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)")
|
|
}
|
|
}
|
|
|
|
@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)
|
|
|
|
guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)"); return }
|
|
#expect(failure.violations.contains(where: { $0.type == .mustStop }),
|
|
"Should have mustStop violation")
|
|
}
|
|
|
|
@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"
|
|
)
|
|
|
|
// Case 1: No repeat cities — filter is a no-op
|
|
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")
|
|
#expect(once.count == 1, "NYC→BOS has no repeat cities, should survive filter")
|
|
|
|
// Case 2: Route with repeat cities — filter actually removes it
|
|
let stop3 = ItineraryStop(
|
|
city: "New York", state: "NY",
|
|
coordinate: TestFixtures.coordinates["New York"],
|
|
games: ["g3"],
|
|
arrivalDate: TestClock.addingDays(2),
|
|
departureDate: TestClock.addingDays(3),
|
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
|
firstGameStart: TestClock.addingDays(2)
|
|
)
|
|
let seg2 = TestFixtures.travelSegment(from: "Boston", to: "New York")
|
|
let repeatOption = ItineraryOption(
|
|
rank: 2, stops: [stop1, stop2, stop3],
|
|
travelSegments: [segment, seg2],
|
|
totalDrivingHours: 7.0, totalDistanceMiles: 430,
|
|
geographicRationale: "test"
|
|
)
|
|
let mixedOnce = RouteFilters.filterRepeatCities([option, repeatOption], allow: false)
|
|
let mixedTwice = RouteFilters.filterRepeatCities(mixedOnce, allow: false)
|
|
#expect(mixedOnce.count == mixedTwice.count, "Double-filter should be idempotent")
|
|
#expect(mixedOnce.count == 1, "Route with repeat NYC should be filtered out")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
switch result {
|
|
case .success:
|
|
break // Success is fine — engine found a valid route visiting NYC exactly once
|
|
case .failure(let failure):
|
|
let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"])
|
|
|| failure.reason == .noValidRoutes
|
|
|| failure.reason == .noGamesInRange
|
|
#expect(hasReason, "Should fail with a clear reason")
|
|
}
|
|
}
|
|
|
|
@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)
|
|
|
|
// With must-stop NYC, some routes may be filtered. Verify:
|
|
// 1. The warnings property is accessible (doesn't crash)
|
|
// 2. If warnings exist, they are all severity .warning
|
|
let warnings = engine.warnings
|
|
for warning in warnings {
|
|
#expect(warning.severity == .warning,
|
|
"Exclusion notices should be warnings, not errors")
|
|
}
|
|
// The engine should produce either success with must-stop satisfied, or failure
|
|
switch result {
|
|
case .success(let options):
|
|
for option in options {
|
|
let cities = option.stops.map { $0.city.lowercased() }
|
|
#expect(cities.contains("new york"), "Must-stop NYC should be in every option")
|
|
}
|
|
case .failure:
|
|
break // Acceptable if no route can satisfy must-stop
|
|
}
|
|
}
|
|
}
|