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>
1002 lines
40 KiB
Swift
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: 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
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
}
|