Files
Sportstime/SportsTimeTests/Planning/ImprovementPlanTDDTests.swift
Trey T a6f538dfed Audit and fix 52 test correctness issues across 22 files
Systematic audit of 1,191 tests found tests written to pass rather than
verify correctness. Key fixes:

Infrastructure:
- TestClock: fixed timezone from .current to America/New_York (deterministic)
- TestFixtures: added 1.3x road routing factor to match production
- ItineraryTestHelpers: real per-city coordinates instead of hardcoded (40,-80)

Planning tests:
- Added missing Scenario E factory dispatch tests
- Tightened 12 loose assertions (>= 1 → == 8.0, > 0 → range checks)
- Fixed 4 no-op tests that accepted both success and failure
- Fixed wrong repeat-city invariant (was checking same-day, not different-day)
- Fixed tautological assertion in missing-stadium edge case

Services/Domain/Export tests:
- Replaced 4 placeholder tests (#expect(true)) with real assertions
- Fixed tautological assertions in POISearchServiceTests
- Fixed Chicago coordinate in RegionMapSelectorTests (-89 → -87.6553)
- Added sort order verification to ItineraryRowFlatteningTests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:00:46 -05:00

1002 lines
40 KiB
Swift

//
// 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)
}
}
// Direct should produce lower-mileage first routes than scenic
if let directFirst = directRoutes.first, let scenicFirst = scenicRoutes.first {
let directMiles = directFirst.compactMap { game -> Double? in
guard let idx = directFirst.firstIndex(where: { $0.id == game.id }),
idx > 0,
let from = stadiums[directFirst[idx - 1].stadiumId],
let to = stadiums[game.stadiumId] else { return nil }
return TravelEstimator.haversineDistanceMiles(from: from.coordinate, to: to.coordinate) * 1.3
}.reduce(0, +)
let scenicMiles = scenicFirst.compactMap { game -> Double? in
guard let idx = scenicFirst.firstIndex(where: { $0.id == game.id }),
idx > 0,
let from = stadiums[scenicFirst[idx - 1].stadiumId],
let to = stadiums[game.stadiumId] else { return nil }
return TravelEstimator.haversineDistanceMiles(from: from.coordinate, to: to.coordinate) * 1.3
}.reduce(0, +)
#expect(directMiles <= scenicMiles, "Direct first route should have <= mileage than scenic first route")
}
}
@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")
// With 2 teams, teamFirstMaxDays = 4. The sliding window needs the game
// span to be wide enough so a 4-day window can contain both games.
// Space games 3 days apart so a window from June 1 to June 5 covers both.
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: 3, 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.
guard case .success(let options) = result else {
Issue.record("Expected .success for NYC + Boston (nearby East Coast teams), got \(result)")
return
}
#expect(!options.isEmpty, "Should find routes for NYC + Boston")
}
}
// 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: NYCLA ~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
}
for option in options {
// Group stops by city, check no city appears on multiple calendar days
var cityDays: [String: Set<Date>] = [:]
let calendar = TestClock.calendar
for stop in option.stops {
let day = calendar.startOfDay(for: stop.arrivalDate)
let city = stop.city.lowercased()
cityDays[city, default: []].insert(day)
}
for (city, days) in cityDays {
#expect(days.count <= 1, "City '\(city)' appears on \(days.count) different days — repeat city violation")
}
}
}
@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")
}
}
}