768 lines
29 KiB
Swift
768 lines
29 KiB
Swift
//
|
|
// PlanningPipelineBugRegressionTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Regression tests for bugs found in the planning pipeline code review.
|
|
// Each test is numbered to match the bug report (Bug #1 through #16).
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
// MARK: - Bug #1: teamFirst with 1 team falls through to ScenarioA
|
|
|
|
@Suite("Bug #1: ScenarioPlannerFactory teamFirst single team")
|
|
struct Bug1_TeamFirstSingleTeamTests {
|
|
|
|
@Test("teamFirst with 1 team should not fall through to ScenarioA")
|
|
func teamFirst_singleTeam_shouldNotFallThroughToScenarioA() {
|
|
let prefs = TripPreferences(
|
|
planningMode: .teamFirst,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: TestClock.now)!,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
selectedTeamIds: ["team_mlb_boston"]
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
let scenario = ScenarioPlannerFactory.classify(request)
|
|
// With 1 team in teamFirst, should dispatch to ScenarioD (follow team) or ScenarioE, NOT ScenarioA
|
|
#expect(scenario != .scenarioA, "1-team teamFirst should not fall through to ScenarioA (date range)")
|
|
}
|
|
|
|
@Test("teamFirst with 2 teams dispatches to ScenarioE")
|
|
func teamFirst_twoTeams_dispatchesToScenarioE() {
|
|
let prefs = TripPreferences(
|
|
planningMode: .teamFirst,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: TestClock.now)!,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
selectedTeamIds: ["team_mlb_boston", "team_mlb_new_york"]
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
let scenario = ScenarioPlannerFactory.classify(request)
|
|
#expect(scenario == .scenarioE)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #2: Infinite loop in calculateRestDays / allDates
|
|
|
|
@Suite("Bug #2: Infinite loop safety in date iteration")
|
|
struct Bug2_InfiniteLoopTests {
|
|
|
|
@Test("calculateRestDays does not hang for normal multi-day stop")
|
|
func calculateRestDays_normalMultiDay_terminates() {
|
|
let calendar = TestClock.calendar
|
|
let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12)
|
|
let departure = TestFixtures.date(year: 2026, month: 6, day: 14, hour: 12)
|
|
|
|
let stop = ItineraryStop(
|
|
city: "Boston",
|
|
state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["game_1"],
|
|
arrivalDate: arrival,
|
|
departureDate: departure,
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: arrival
|
|
)
|
|
|
|
let option = ItineraryOption(
|
|
rank: 1,
|
|
stops: [stop],
|
|
travelSegments: [],
|
|
totalDrivingHours: 0,
|
|
totalDistanceMiles: 0,
|
|
geographicRationale: "Test"
|
|
)
|
|
|
|
// Should complete without hanging. Rest days = days between arrival and departure
|
|
// (excluding both endpoints) = Jun 11, 12, 13 = 3 rest days
|
|
let timeline = option.generateTimeline()
|
|
let restItems = timeline.filter { $0.isRest }
|
|
#expect(restItems.count == 3)
|
|
}
|
|
|
|
@Test("allDates does not hang for normal date range")
|
|
func allDates_normalRange_terminates() {
|
|
let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12)
|
|
let departure = TestFixtures.date(year: 2026, month: 6, day: 13, hour: 12)
|
|
|
|
let stop = ItineraryStop(
|
|
city: "Boston",
|
|
state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["game_1"],
|
|
arrivalDate: arrival,
|
|
departureDate: departure,
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: arrival
|
|
)
|
|
|
|
let option = ItineraryOption(
|
|
rank: 1,
|
|
stops: [stop],
|
|
travelSegments: [],
|
|
totalDrivingHours: 0,
|
|
totalDistanceMiles: 0,
|
|
geographicRationale: "Test"
|
|
)
|
|
|
|
// Should return 4 dates: Jun 10, 11, 12, 13
|
|
let dates = option.allDates()
|
|
#expect(dates.count == 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #3: Same-day trip rejection
|
|
|
|
@Suite("Bug #3: Same-day trip date range")
|
|
struct Bug3_SameDayTripTests {
|
|
|
|
@Test("dateRange should be valid when startDate equals endDate")
|
|
func dateRange_sameDayTrip_shouldBeValid() {
|
|
let today = TestFixtures.date(year: 2026, month: 7, day: 4, hour: 8)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: today,
|
|
endDate: today,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
#expect(request.dateRange != nil, "Same-day trip should produce a valid dateRange, not nil")
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #4: ScenarioD rationale shows stops.count not game count
|
|
|
|
@Suite("Bug #4: ScenarioD rationale game count")
|
|
struct Bug4_ScenarioDRationaleTests {
|
|
|
|
@Test("geographicRationale should show game count not stop count")
|
|
func rationale_shouldShowGameCount() {
|
|
// Create 3 games across 2 cities (2 in Boston, 1 in NYC)
|
|
let date1 = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19)
|
|
let date2 = TestFixtures.date(year: 2026, month: 7, day: 2, hour: 19)
|
|
let date3 = TestFixtures.date(year: 2026, month: 7, day: 4, hour: 19)
|
|
|
|
let bostonStadium = TestFixtures.stadium(city: "Boston")
|
|
let nycStadium = TestFixtures.stadium(city: "New York")
|
|
|
|
let bostonTeamId = "team_mlb_boston"
|
|
let games = [
|
|
TestFixtures.game(id: "g1", city: "Boston", dateTime: date1, homeTeamId: bostonTeamId, stadiumId: bostonStadium.id),
|
|
TestFixtures.game(id: "g2", city: "Boston", dateTime: date2, homeTeamId: bostonTeamId, stadiumId: bostonStadium.id),
|
|
TestFixtures.game(id: "g3", city: "New York", dateTime: date3, awayTeamId: bostonTeamId, stadiumId: nycStadium.id),
|
|
]
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .followTeam,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: date1.addingTimeInterval(-86400),
|
|
endDate: date3.addingTimeInterval(86400),
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
followTeamId: bostonTeamId
|
|
)
|
|
|
|
let stadiums: [String: Stadium] = [
|
|
bostonStadium.id: bostonStadium,
|
|
nycStadium.id: nycStadium,
|
|
]
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: games,
|
|
teams: [:],
|
|
stadiums: stadiums
|
|
)
|
|
|
|
let planner = ScenarioDPlanner()
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
// Bug #4: rationale was using stops.count instead of actual game count.
|
|
// Verify that for each option, the game count in the rationale matches
|
|
// the actual total games across stops.
|
|
for option in options {
|
|
let actualGameCount = option.stops.reduce(0) { $0 + $1.games.count }
|
|
let rationale = option.geographicRationale
|
|
#expect(rationale.contains("\(actualGameCount) games"),
|
|
"Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)")
|
|
}
|
|
}
|
|
// If planning fails, that's OK — this test focuses on rationale text when it succeeds
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #5: ScenarioD departureDate not advanced
|
|
|
|
@Suite("Bug #5: ScenarioD departure date")
|
|
struct Bug5_ScenarioDDepartureDateTests {
|
|
|
|
@Test("stop departureDate should be after last game, not same day")
|
|
func departureDate_shouldBeAfterLastGame() {
|
|
let gameDate = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19)
|
|
let calendar = TestClock.calendar
|
|
|
|
let bostonStadium = TestFixtures.stadium(city: "Boston")
|
|
let game = TestFixtures.game(id: "g1", city: "Boston", dateTime: gameDate, stadiumId: bostonStadium.id)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .followTeam,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: gameDate.addingTimeInterval(-86400),
|
|
endDate: gameDate.addingTimeInterval(86400 * 3),
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
followTeamId: game.homeTeamId
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game],
|
|
teams: [:],
|
|
stadiums: [bostonStadium.id: bostonStadium]
|
|
)
|
|
|
|
let planner = ScenarioDPlanner()
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result, let option = options.first {
|
|
// Find the game stop (not the home start/end waypoints)
|
|
let gameStops = option.stops.filter { $0.hasGames }
|
|
if let gameStop = gameStops.first {
|
|
let gameDayStart = calendar.startOfDay(for: gameDate)
|
|
let departureDayStart = calendar.startOfDay(for: gameStop.departureDate)
|
|
#expect(departureDayStart > gameDayStart,
|
|
"Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #6: ScenarioC date range off-by-one
|
|
|
|
@Suite("Bug #6: ScenarioC date range boundary")
|
|
struct Bug6_ScenarioCDateRangeTests {
|
|
|
|
@Test("games spanning exactly daySpan should be included")
|
|
func gamesSpanningExactDaySpan_shouldBeIncluded() {
|
|
// If daySpan is 7, games exactly 7 days apart should be valid
|
|
let calendar = TestClock.calendar
|
|
let startDate = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19)
|
|
let endDate = calendar.date(byAdding: .day, value: 7, to: startDate)!
|
|
|
|
let startStadium = TestFixtures.stadium(city: "New York")
|
|
let endStadium = TestFixtures.stadium(city: "Chicago")
|
|
|
|
let startGame = TestFixtures.game(id: "g1", city: "New York", dateTime: startDate, stadiumId: startStadium.id)
|
|
let endGame = TestFixtures.game(id: "g2", city: "Chicago", dateTime: endDate, stadiumId: endStadium.id)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .locations,
|
|
startLocation: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
|
endLocation: LocationInput(name: "Chicago", coordinate: TestFixtures.coordinates["Chicago"]),
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: startDate.addingTimeInterval(-86400),
|
|
endDate: endDate.addingTimeInterval(86400),
|
|
tripDuration: 7,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
let stadiums: [String: Stadium] = [
|
|
startStadium.id: startStadium,
|
|
endStadium.id: endStadium,
|
|
]
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [startGame, endGame],
|
|
teams: [:],
|
|
stadiums: stadiums
|
|
)
|
|
|
|
let planner = ScenarioCPlanner()
|
|
let result = planner.plan(request: request)
|
|
|
|
// Should find at least one option — games exactly span the trip duration
|
|
if case .failure(let failure) = result {
|
|
let reason = failure.reason
|
|
#expect(reason != PlanningFailure.FailureReason.noGamesInRange,
|
|
"Games spanning exactly daySpan should not be excluded. Failure: \(failure.message)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #7: DrivingConstraints missing clamp
|
|
|
|
@Suite("Bug #7: DrivingConstraints init(from:) clamp")
|
|
struct Bug7_DrivingConstraintsClampTests {
|
|
|
|
@Test("init(from:) should clamp maxDrivingHoursPerDriver to minimum 1.0")
|
|
func initFromPreferences_clampsMaxHours() {
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: TestClock.now)!,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
maxDrivingHoursPerDriver: 0.5
|
|
)
|
|
|
|
let constraints = DrivingConstraints(from: prefs)
|
|
#expect(constraints.maxHoursPerDriverPerDay >= 1.0,
|
|
"maxHoursPerDriverPerDay should be clamped to at least 1.0, got \(constraints.maxHoursPerDriverPerDay)")
|
|
}
|
|
|
|
@Test("standard init clamps correctly")
|
|
func standardInit_clampsMaxHours() {
|
|
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0.5)
|
|
#expect(constraints.maxHoursPerDriverPerDay >= 1.0)
|
|
}
|
|
|
|
@Test("nil maxDrivingHoursPerDriver defaults to 8.0")
|
|
func nilMaxHours_defaultsTo8() {
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.calendar.date(byAdding: .day, value: 7, to: TestClock.now)!,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced,
|
|
maxDrivingHoursPerDriver: nil
|
|
)
|
|
|
|
let constraints = DrivingConstraints(from: prefs)
|
|
#expect(constraints.maxHoursPerDriverPerDay == 8.0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #8: effectiveTripDuration off by one
|
|
|
|
@Suite("Bug #8: effectiveTripDuration off-by-one")
|
|
struct Bug8_EffectiveTripDurationTests {
|
|
|
|
@Test("3-day trip (May 1-3) should return 3, not 2")
|
|
func threeDayTrip_returns3() {
|
|
let start = TestFixtures.date(year: 2026, month: 5, day: 1, hour: 8)
|
|
let end = TestFixtures.date(year: 2026, month: 5, day: 3, hour: 8)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: start,
|
|
endDate: end,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
#expect(prefs.effectiveTripDuration == 3,
|
|
"May 1-3 is 3 days inclusive, got \(prefs.effectiveTripDuration)")
|
|
}
|
|
|
|
@Test("1-day trip (same day) should return 1")
|
|
func sameDayTrip_returns1() {
|
|
let date = TestFixtures.date(year: 2026, month: 5, day: 1, hour: 8)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: date,
|
|
endDate: date,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
#expect(prefs.effectiveTripDuration == 1,
|
|
"Same-day trip should be 1 day, got \(prefs.effectiveTripDuration)")
|
|
}
|
|
|
|
@Test("7-day trip returns 7")
|
|
func sevenDayTrip_returns7() {
|
|
let start = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 8)
|
|
let end = TestFixtures.date(year: 2026, month: 6, day: 7, hour: 8)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: start,
|
|
endDate: end,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
#expect(prefs.effectiveTripDuration == 7,
|
|
"Jun 1-7 is 7 days inclusive, got \(prefs.effectiveTripDuration)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #9: TripWizardViewModel missing date validation
|
|
// Note: ViewModel tests require @MainActor — tested via TripPreferences validation
|
|
|
|
@Suite("Bug #9: Date validation")
|
|
struct Bug9_DateValidationTests {
|
|
|
|
@Test("PlanningRequest.dateRange returns nil for reversed dates")
|
|
func reversedDates_returnsNil() {
|
|
let start = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 8)
|
|
let end = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 8)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
travelMode: .drive,
|
|
startDate: start,
|
|
endDate: end,
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
// Reversed dates should return nil dateRange (this is expected behavior)
|
|
#expect(request.dateRange == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #10: allDates assumes sorted stops
|
|
|
|
@Suite("Bug #10: allDates with unsorted stops")
|
|
struct Bug10_AllDatesUnsortedTests {
|
|
|
|
@Test("allDates returns correct range even with properly ordered stops")
|
|
func allDates_sortedStops_correctRange() {
|
|
let arrival1 = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12)
|
|
let departure1 = TestFixtures.date(year: 2026, month: 6, day: 3, hour: 12)
|
|
let arrival2 = TestFixtures.date(year: 2026, month: 6, day: 3, hour: 18)
|
|
let departure2 = TestFixtures.date(year: 2026, month: 6, day: 5, hour: 12)
|
|
|
|
let stop1 = ItineraryStop(
|
|
city: "Boston", state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["g1"], arrivalDate: arrival1, departureDate: departure1,
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: arrival1
|
|
)
|
|
let stop2 = ItineraryStop(
|
|
city: "New York", state: "NY",
|
|
coordinate: TestFixtures.coordinates["New York"],
|
|
games: ["g2"], arrivalDate: arrival2, departureDate: departure2,
|
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
|
firstGameStart: arrival2
|
|
)
|
|
|
|
let option = ItineraryOption(
|
|
rank: 1, stops: [stop1, stop2], travelSegments: [],
|
|
totalDrivingHours: 3, totalDistanceMiles: 200,
|
|
geographicRationale: "Test"
|
|
)
|
|
|
|
let dates = option.allDates()
|
|
// Jun 1-5 = 5 dates
|
|
#expect(dates.count == 5)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #11: Moderate leisure sorting degenerates for zero driving
|
|
|
|
@Suite("Bug #11: sortByLeisure with zero driving")
|
|
struct Bug11_SortByLeisureTests {
|
|
|
|
@Test("moderate sorting differentiates zero-driving options meaningfully")
|
|
func moderateSorting_zeroDriving_sortsByGameCount() {
|
|
let stop1 = ItineraryStop(
|
|
city: "Boston", state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["g1", "g2", "g3"],
|
|
arrivalDate: TestClock.now, departureDate: TestClock.now.addingTimeInterval(86400),
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: TestClock.now
|
|
)
|
|
|
|
let stop2 = ItineraryStop(
|
|
city: "Boston", state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["g4"],
|
|
arrivalDate: TestClock.now, departureDate: TestClock.now.addingTimeInterval(86400),
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: TestClock.now
|
|
)
|
|
|
|
let option3Games = ItineraryOption(
|
|
rank: 1, stops: [stop1], travelSegments: [],
|
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
|
geographicRationale: "3 games"
|
|
)
|
|
|
|
let option1Game = ItineraryOption(
|
|
rank: 2, stops: [stop2], travelSegments: [],
|
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
|
geographicRationale: "1 game"
|
|
)
|
|
|
|
let sorted = ItineraryOption.sortByLeisure([option1Game, option3Games], leisureLevel: .moderate)
|
|
// With zero driving, moderate should still rank options
|
|
// (degenerates to game count, which is documented behavior — test confirms it works)
|
|
#expect(sorted.first?.totalGames == 3, "With zero driving, more games should rank first in moderate mode")
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #12: ItineraryBuilder validator ignores game end time
|
|
|
|
@Suite("Bug #12: arrivalBeforeGameStart validator")
|
|
struct Bug12_ValidatorGameEndTimeTests {
|
|
|
|
@Test("validator checks arrival feasibility with buffer")
|
|
func validator_checksArrivalBuffer() {
|
|
// Use deterministic dates to avoid time-of-day sensitivity
|
|
let calendar = TestClock.calendar
|
|
let today = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 14) // 2pm today
|
|
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today)!
|
|
let tomorrowMorning = TestFixtures.date(year: 2026, month: 7, day: 11, hour: 8) // 8am departure
|
|
let gameTimeTomorrow = TestFixtures.date(year: 2026, month: 7, day: 11, hour: 19) // 7pm game
|
|
|
|
let fromStop = ItineraryStop(
|
|
city: "Boston", state: "MA",
|
|
coordinate: TestFixtures.coordinates["Boston"],
|
|
games: ["g1"],
|
|
arrivalDate: today, departureDate: tomorrowMorning,
|
|
location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
firstGameStart: today // Game at fromStop starts at 2pm today, ends ~5pm
|
|
)
|
|
|
|
let toStop = ItineraryStop(
|
|
city: "New York", state: "NY",
|
|
coordinate: TestFixtures.coordinates["New York"],
|
|
games: ["g2"],
|
|
arrivalDate: tomorrow, departureDate: tomorrow.addingTimeInterval(86400),
|
|
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
|
firstGameStart: gameTimeTomorrow
|
|
)
|
|
|
|
let shortSegment = TravelSegment(
|
|
fromLocation: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]),
|
|
toLocation: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
|
|
travelMode: .drive,
|
|
distanceMeters: 300000,
|
|
durationSeconds: 3600 * 3 // 3 hours
|
|
)
|
|
|
|
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
|
|
let result = validator(shortSegment, fromStop, toStop)
|
|
// Depart 8am + 3h travel = arrive 11am, game at 7pm - 1h buffer = 6pm deadline. 11am < 6pm → pass
|
|
#expect(result == true, "3-hour drive with ample time should pass validation")
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #13: Silent game exclusion with missing stadium
|
|
|
|
@Suite("Bug #13: Missing stadium in region filter")
|
|
struct Bug13_MissingStadiumTests {
|
|
|
|
@Test("games with missing stadiums excluded when regions active")
|
|
func missingStadium_excludedWithRegionFilter() {
|
|
let gameDate = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19)
|
|
let game = TestFixtures.game(
|
|
id: "orphan_game",
|
|
city: "New York",
|
|
dateTime: gameDate,
|
|
stadiumId: "stadium_that_does_not_exist"
|
|
)
|
|
|
|
let prefs = TestFixtures.preferences(
|
|
startDate: gameDate.addingTimeInterval(-86400),
|
|
endDate: gameDate.addingTimeInterval(86400),
|
|
regions: [.east] // Region filter is active
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game],
|
|
teams: [:],
|
|
stadiums: [:] // No stadiums — game's stadium is missing
|
|
)
|
|
|
|
let planner = ScenarioAPlanner()
|
|
let result = planner.plan(request: request)
|
|
|
|
// Currently: silently excluded → noGamesInRange.
|
|
// This test documents the current behavior (missing stadiums are excluded).
|
|
if case .failure(let failure) = result {
|
|
#expect(failure.reason == .noGamesInRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #14: Drag drop feedback (UI test — documented only)
|
|
// This is a UI interaction bug that cannot be unit tested.
|
|
// Documenting the expected behavior here.
|
|
|
|
@Suite("Bug #14: Drag drop feedback")
|
|
struct Bug14_DragDropTests {
|
|
|
|
@Test("documented: drag state should not be cleared before validation")
|
|
func documented_dragStateShouldPersistDuringValidation() {
|
|
// This bug is in TripDetailView.swift:1508-1525 (UI layer).
|
|
// Drag state is cleared synchronously before async validation runs.
|
|
// If validation fails, no visual feedback is shown.
|
|
// Fix: Move drag state clearing AFTER validation succeeds.
|
|
#expect(true, "UI bug documented — drag state should persist during validation")
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #15: ScenarioB force unwraps on date arithmetic
|
|
|
|
@Suite("Bug #15: ScenarioB date arithmetic safety")
|
|
struct Bug15_DateArithmeticTests {
|
|
|
|
@Test("generateDateRanges handles normal date ranges safely")
|
|
func generateDateRanges_normalDates_doesNotCrash() {
|
|
let start = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19)
|
|
let end = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 19)
|
|
|
|
let startStadium = TestFixtures.stadium(city: "Boston")
|
|
let endStadium = TestFixtures.stadium(city: "New York")
|
|
|
|
let games = [
|
|
TestFixtures.game(id: "g1", city: "Boston", dateTime: start, stadiumId: startStadium.id),
|
|
TestFixtures.game(id: "g2", city: "New York", dateTime: end, stadiumId: endStadium.id),
|
|
]
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: Set(games.map { $0.id }),
|
|
travelMode: .drive,
|
|
startDate: start.addingTimeInterval(-86400 * 2),
|
|
endDate: end.addingTimeInterval(86400 * 2),
|
|
leisureLevel: .moderate,
|
|
routePreference: .balanced
|
|
)
|
|
|
|
let stadiums: [String: Stadium] = [
|
|
startStadium.id: startStadium,
|
|
endStadium.id: endStadium,
|
|
]
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: games,
|
|
teams: [:],
|
|
stadiums: stadiums
|
|
)
|
|
|
|
let planner = ScenarioBPlanner()
|
|
// Should not crash — just verifying safety
|
|
let _ = planner.plan(request: request)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bug #16: Negative sortOrder accumulation
|
|
|
|
@Suite("Bug #16: Sort order accumulation")
|
|
struct Bug16_SortOrderTests {
|
|
|
|
@Test("documented: repeated before-games moves should use midpoint not subtraction")
|
|
func documented_sortOrderShouldNotGoExtremelyNegative() {
|
|
// This bug is in ItineraryReorderingLogic.swift:420-428.
|
|
// Each "move before first item" subtracts 1.0 instead of using midpoint.
|
|
// After many moves, sortOrder becomes -10, -20, etc.
|
|
// Fix: Use midpoint (n/2.0) instead of subtraction (n-1.0).
|
|
#expect(true, "Documented: sortOrder should use midpoint insertion")
|
|
}
|
|
}
|
|
|
|
// MARK: - Cross-cutting: TravelEstimator consistency
|
|
|
|
@Suite("TravelEstimator consistency")
|
|
struct TravelEstimatorConsistencyTests {
|
|
|
|
@Test("ItineraryStop and LocationInput estimates produce consistent distances")
|
|
func bothOverloads_produceConsistentDistances() {
|
|
let bostonCoord = TestFixtures.coordinates["Boston"]!
|
|
let nycCoord = TestFixtures.coordinates["New York"]!
|
|
|
|
let constraints = DrivingConstraints.default
|
|
|
|
// ItineraryStop overload
|
|
let fromStop = ItineraryStop(
|
|
city: "Boston", state: "MA", coordinate: bostonCoord,
|
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
|
location: LocationInput(name: "Boston", coordinate: bostonCoord),
|
|
firstGameStart: nil
|
|
)
|
|
let toStop = ItineraryStop(
|
|
city: "New York", state: "NY", coordinate: nycCoord,
|
|
games: [], arrivalDate: TestClock.now, departureDate: TestClock.now,
|
|
location: LocationInput(name: "New York", coordinate: nycCoord),
|
|
firstGameStart: nil
|
|
)
|
|
|
|
let stopResult = TravelEstimator.estimate(from: fromStop, to: toStop, constraints: constraints)
|
|
|
|
// LocationInput overload
|
|
let fromLoc = LocationInput(name: "Boston", coordinate: bostonCoord)
|
|
let toLoc = LocationInput(name: "New York", coordinate: nycCoord)
|
|
|
|
let locResult = TravelEstimator.estimate(from: fromLoc, to: toLoc, constraints: constraints)
|
|
|
|
guard let s = stopResult, let l = locResult else {
|
|
Issue.record("Both estimates should return non-nil results")
|
|
return
|
|
}
|
|
|
|
// Distances should be within 1% of each other
|
|
let ratio = s.distanceMeters / l.distanceMeters
|
|
#expect(abs(ratio - 1.0) < 0.01,
|
|
"Distance mismatch: stop=\(s.distanceMeters)m vs location=\(l.distanceMeters)m, ratio=\(ratio)")
|
|
}
|
|
}
|