Harden planning test suite with realistic fixtures and output sanity checks

Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests,
and updates all scenario planner tests with improved coverage and assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-04 13:38:41 -05:00
parent 188076717b
commit 9b622f8bbb
13 changed files with 2174 additions and 446 deletions

View File

@@ -467,3 +467,108 @@ extension TestFixtures {
static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"]
static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"]
}
// MARK: - Messy / Realistic Data Factories
extension TestFixtures {
/// Creates games that are all in the past relative to a reference date.
static func pastGames(
count: Int,
sport: Sport = .mlb,
cities: [String] = ["New York", "Boston", "Chicago"],
referenceDate: Date = TestClock.now
) -> [Game] {
(0..<count).map { i in
let city = cities[i % cities.count]
let daysAgo = 30 + (i * 2) // 30-60 days in the past
let gameDate = TestClock.calendar.date(byAdding: .day, value: -daysAgo, to: referenceDate)!
return game(sport: sport, city: city, dateTime: gameDate)
}
}
/// Creates a mix of past and future games, returning them categorized.
static func mixedPastFutureGames(
pastCount: Int = 5,
futureCount: Int = 5,
sport: Sport = .mlb,
cities: [String] = ["New York", "Boston", "Chicago", "Philadelphia"],
referenceDate: Date = TestClock.now
) -> (past: [Game], future: [Game], all: [Game]) {
let past = (0..<pastCount).map { i in
let city = cities[i % cities.count]
let gameDate = TestClock.calendar.date(byAdding: .day, value: -(i + 1) * 5, to: referenceDate)!
return game(id: "past_\(i)", sport: sport, city: city, dateTime: gameDate)
}
let future = (0..<futureCount).map { i in
let city = cities[i % cities.count]
let gameDate = TestClock.calendar.date(byAdding: .day, value: (i + 1) * 2, to: referenceDate)!
return game(id: "future_\(i)", sport: sport, city: city, dateTime: gameDate)
}
return (past, future, past + future)
}
/// Creates games from sports the user didn't select (for testing sport filtering).
static func gamesWithWrongSport(
selectedSport: Sport = .mlb,
wrongSport: Sport = .nba,
correctCount: Int = 3,
wrongCount: Int = 3,
cities: [String] = ["New York", "Boston", "Chicago"]
) -> (correct: [Game], wrong: [Game], all: [Game]) {
let start = TestClock.addingDays(1)
let correct = (0..<correctCount).map { i in
let city = cities[i % cities.count]
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
return game(id: "correct_\(i)", sport: selectedSport, city: city, dateTime: dt)
}
let wrong = (0..<wrongCount).map { i in
let city = cities[i % cities.count]
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
return game(id: "wrong_\(i)", sport: wrongSport, city: city, dateTime: dt)
}
return (correct, wrong, correct + wrong)
}
/// Creates games referencing stadium IDs that don't exist in any stadium map.
static func gamesWithMissingStadium(
count: Int = 3,
sport: Sport = .mlb
) -> [Game] {
let start = TestClock.addingDays(1)
return (0..<count).map { i in
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
return game(
id: "orphan_\(i)",
sport: sport,
city: "Atlantis",
dateTime: dt,
stadiumId: "stadium_nonexistent_\(i)"
)
}
}
/// Creates two different games sharing the same ID (simulates rescheduled games).
static func duplicateIdGames(sport: Sport = .mlb) -> [Game] {
let dt1 = TestClock.addingDays(2)
let dt2 = TestClock.addingDays(3)
return [
game(id: "dup_game_001", sport: sport, city: "New York", dateTime: dt1),
game(id: "dup_game_001", sport: sport, city: "Boston", dateTime: dt2),
]
}
/// Creates games spread over many days for long-trip duration testing.
static func longTripGames(
days: Int = 30,
sport: Sport = .mlb,
cities: [String] = ["New York", "Boston", "Chicago", "Philadelphia", "Atlanta"]
) -> [Game] {
let start = TestClock.addingDays(1)
return (0..<days).map { i in
let city = cities[i % cities.count]
let dt = TestClock.calendar.date(byAdding: .day, value: i, to: start)!
return game(id: "long_\(i)", sport: sport, city: city, dateTime: dt)
}
}
}

View File

@@ -150,18 +150,12 @@ struct Phase1B_ScenarioERegionTests {
let result = planner.plan(request: request)
// With only East region, LA team has no home games should fail
if case .failure(let failure) = result {
#expect(failure.reason == .noGamesInRange,
"Should fail because LA team has no East region games")
}
// If success, verify no LA stops
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"),
"East-only filter should exclude LA")
}
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")
@@ -204,11 +198,14 @@ struct Phase1B_ScenarioERegionTests {
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// Should succeed with both nearby East Coast teams
if case .success(let options) = result {
// Should succeed with both nearby East Coast teams.
// Failure is also OK if driving constraints prevent it.
switch result {
case .success(let options):
#expect(!options.isEmpty, "Should find routes for NYC + Boston")
case .failure:
break // Acceptable driving constraints may prevent a valid route
}
// Failure is also OK if driving constraints prevent it
}
}
@@ -246,11 +243,13 @@ struct Phase1C_MustStopTests {
let result = engine.planItineraries(request: request)
// If successful, all options must include Boston
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "All options must include Boston must-stop")
}
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")
}
}
}
@@ -330,10 +329,12 @@ struct Phase1D_TravelSegmentTests {
let result = engine.planItineraries(request: request)
// If successful, all returned options must be valid
if case .success(let options) = result {
for option in options {
#expect(option.isValid, "Engine should only return valid options")
}
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")
}
}
}
@@ -553,7 +554,7 @@ struct Phase2C_OvernightRestTests {
@Suite("Phase 2D: Silent Exclusion Warnings")
struct Phase2D_ExclusionWarningTests {
@Test("Engine tracks warnings when options are excluded by repeat city filter")
@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)!
@@ -580,11 +581,24 @@ struct Phase2D_ExclusionWarningTests {
)
let engine = TripPlanningEngine()
_ = engine.planItineraries(request: request)
let result = engine.planItineraries(request: request)
// Engine should have warnings accessible (even if result is failure)
// The warnings property exists and is populated
#expect(engine.warnings is [ConstraintViolation], "Warnings should be an array of ConstraintViolation")
// 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
}
// Every returned route must have unique cities per calendar day
for option in options {
let calendar = Calendar.current
var cityDays: Set<String> = []
for stop in option.stops {
let day = calendar.startOfDay(for: stop.arrivalDate)
let key = "\(stop.city.lowercased())_\(day.timeIntervalSince1970)"
#expect(!cityDays.contains(key), "Route should not visit \(stop.city) on the same day twice")
cityDays.insert(key)
}
}
}
@Test("Engine tracks must-stop exclusion warnings")
@@ -615,10 +629,12 @@ struct Phase2D_ExclusionWarningTests {
let result = engine.planItineraries(request: request)
// Should fail with must-stop violation
if case .failure(let failure) = result {
let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop })
#expect(hasMustStopViolation, "Failure should include mustStop constraint 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")
@@ -636,10 +652,15 @@ struct Phase2D_ExclusionWarningTests {
let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:])
_ = engine.planItineraries(request: request)
// After a second run, warnings should be reset
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
// (engine resets warnings at start of planItineraries)
#expect(warningsAfterSecond.count == warningsAfterFirst.count,
"Warnings should be reset between runs, not accumulated (\(warningsAfterFirst.count) vs \(warningsAfterSecond.count))")
}
}
@@ -845,14 +866,14 @@ struct Phase4B_InvertedDateRangeTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .failure(let failure) = result {
#expect(failure.reason == .missingDateRange,
"Inverted date range should return missingDateRange failure")
#expect(failure.violations.contains(where: { $0.type == .dateRange }),
"Should include dateRange violation")
} else {
#expect(Bool(false), "Inverted date range should not succeed")
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")
}
}
@@ -945,12 +966,14 @@ struct Phase4D_CrossCountryTests {
let result = engine.planItineraries(request: request)
// If successful, should NOT contain LA
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"),
"East-only search should not include 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")
}
}
}

View File

@@ -44,11 +44,10 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Every route must include Boston as a must-stop")
}
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"), "Every route must include Boston as a must-stop")
}
}
@@ -116,11 +115,10 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included")
}
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"), "Must-stop filter should ensure Boston is included")
}
}
@@ -156,41 +154,39 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode")
}
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"), "Must-stop filter should ensure Boston is included in follow-team mode")
}
}
@Test("scenarioE: must stops enforced via centralized filter")
func scenarioE_mustStops_routesContainRequiredCities() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let day1 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
let day2 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
// 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day3 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let teamNYC = "team_mlb_new_york"
let teamBOS = "team_mlb_boston"
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamNYC)
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day1, homeTeamId: teamBOS)
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2, homeTeamId: "team_mlb_philadelphia")
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3, homeTeamId: teamBOS)
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL])
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS])
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
startDate: baseDate,
endDate: day2,
endDate: day3,
mustStopLocations: [LocationInput(name: "Boston")],
selectedTeamIds: [teamNYC, teamBOS]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gameBOS, gamePHL],
availableGames: [gameNYC, gameBOS],
teams: [
teamNYC: TestFixtures.team(city: "New York"),
teamBOS: TestFixtures.team(city: "Boston"),
@@ -201,54 +197,58 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode")
}
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"), "Must-stop filter should ensure Boston is included in team-first mode")
}
}
@Test("scenarioC: must stops enforced via centralized filter")
func scenarioC_mustStops_routesContainRequiredCities() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
// Route: Chicago New York (eastward). Detroit is directionally between them.
let baseDate = TestClock.now
let endDate = TestClock.calendar.date(byAdding: .day, value: 10, to: baseDate)!
let chiCoord = TestFixtures.coordinates["Chicago"]!
let detCoord = TestFixtures.coordinates["Detroit"]!
let nycCoord = TestFixtures.coordinates["New York"]!
let bosCoord = TestFixtures.coordinates["Boston"]!
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate)
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2)
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3)
let chiStadium = TestFixtures.stadium(id: "chi", city: "Chicago")
let detStadium = TestFixtures.stadium(id: "det", city: "Detroit")
let nycStadium = TestFixtures.stadium(id: "nyc", city: "New York")
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gamePHL, gameBOS])
let gameCHI = TestFixtures.game(city: "Chicago", dateTime: TestClock.addingDays(1), stadiumId: "chi")
let gameDET = TestFixtures.game(city: "Detroit", dateTime: TestClock.addingDays(4), stadiumId: "det")
let gameNYC = TestFixtures.game(city: "New York", dateTime: TestClock.addingDays(7), stadiumId: "nyc")
let prefs = TripPreferences(
planningMode: .locations,
startLocation: LocationInput(name: "New York", coordinate: nycCoord),
endLocation: LocationInput(name: "Boston", coordinate: bosCoord),
startLocation: LocationInput(name: "Chicago", coordinate: chiCoord),
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb],
startDate: baseDate,
endDate: day3,
mustStopLocations: [LocationInput(name: "Philadelphia")]
endDate: endDate,
leisureLevel: .moderate,
mustStopLocations: [LocationInput(name: "Detroit")],
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gamePHL, gameBOS],
availableGames: [gameCHI, gameDET, gameNYC],
teams: [:],
stadiums: stadiums
stadiums: ["chi": chiStadium, "det": detStadium, "nyc": nycStadium]
)
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("philadelphia"), "Must-stop filter should ensure Philadelphia is included in route mode")
}
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("detroit"), "Must-stop filter should ensure Detroit is included in Chicago→NYC route")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -198,14 +198,16 @@ struct FilterCascadeTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .failure(let failure) = result {
switch result {
case .success:
break // Success is also acceptable engine found a valid non-repeating route
case .failure(let failure):
// Should get either repeatCityViolation or noGamesInRange/noValidRoutes
let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"])
|| failure.reason == .noValidRoutes
|| failure.reason == .noGamesInRange
#expect(isExpectedFailure, "Should get a clear failure reason, got: \(failure.message)")
}
// Success is also acceptable if engine handles it differently
}
@Test("Must-stop filter with impossible city → clear error")
@@ -235,11 +237,9 @@ struct FilterCascadeTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .failure(let failure) = result {
#expect(failure.violations.contains(where: { $0.type == .mustStop }),
"Should have mustStop violation")
}
// If no routes generated at all (noGamesInRange), that's also an acceptable failure
guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)"); return }
#expect(failure.violations.contains(where: { $0.type == .mustStop }),
"Should have mustStop violation")
}
@Test("Empty sports set produces warning")
@@ -297,11 +297,34 @@ struct FilterCascadeTests {
geographicRationale: "test"
)
// Case 1: No repeat cities filter is a no-op
let options = [option]
let once = RouteFilters.filterRepeatCities(options, allow: false)
let twice = RouteFilters.filterRepeatCities(once, allow: false)
#expect(once.count == twice.count, "Filtering twice should produce same result as once")
#expect(once.count == 1, "NYC→BOS has no repeat cities, should survive filter")
// Case 2: Route with repeat cities filter actually removes it
let stop3 = ItineraryStop(
city: "New York", state: "NY",
coordinate: TestFixtures.coordinates["New York"],
games: ["g3"],
arrivalDate: TestClock.addingDays(2),
departureDate: TestClock.addingDays(3),
location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]),
firstGameStart: TestClock.addingDays(2)
)
let seg2 = TestFixtures.travelSegment(from: "Boston", to: "New York")
let repeatOption = ItineraryOption(
rank: 2, stops: [stop1, stop2, stop3],
travelSegments: [segment, seg2],
totalDrivingHours: 7.0, totalDistanceMiles: 430,
geographicRationale: "test"
)
let mixedOnce = RouteFilters.filterRepeatCities([option, repeatOption], allow: false)
let mixedTwice = RouteFilters.filterRepeatCities(mixedOnce, allow: false)
#expect(mixedOnce.count == mixedTwice.count, "Double-filter should be idempotent")
#expect(mixedOnce.count == 1, "Route with repeat NYC should be filtered out")
}
}
@@ -396,13 +419,15 @@ struct ConstraintInteractionTests {
// Engine should handle this gracefully either find a route that visits NYC once
// or return a clear failure
if case .failure(let failure) = result {
switch result {
case .success:
break // Success is fine engine found a valid route visiting NYC exactly once
case .failure(let failure):
let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"])
|| failure.reason == .noValidRoutes
|| failure.reason == .noGamesInRange
#expect(hasReason, "Should fail with a clear reason")
}
// Success is fine too if engine finds a single-NYC-day route
}
@Test("Multiple drivers extend feasible distance")
@@ -513,11 +538,23 @@ struct ConstraintInteractionTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
// Whether success or failure, warnings should be accessible
// If options were filtered, we should see warnings
if result.isSuccess && !engine.warnings.isEmpty {
#expect(engine.warnings.allSatisfy { $0.severity == .warning },
// With must-stop NYC, some routes may be filtered. Verify:
// 1. The warnings property is accessible (doesn't crash)
// 2. If warnings exist, they are all severity .warning
let warnings = engine.warnings
for warning in warnings {
#expect(warning.severity == .warning,
"Exclusion notices should be warnings, not errors")
}
// The engine should produce either success with must-stop satisfied, or failure
switch result {
case .success(let options):
for option in options {
let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("new york"), "Must-stop NYC should be in every option")
}
case .failure:
break // Acceptable if no route can satisfy must-stop
}
}
}

View File

@@ -212,18 +212,19 @@ struct Bug4_ScenarioDRationaleTests {
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)")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// 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
}
}
@@ -261,15 +262,21 @@ struct Bug5_ScenarioDDepartureDateTests {
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)")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
guard let option = options.first else {
Issue.record("Expected at least one option, got empty array")
return
}
// 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)")
}
}
}
@@ -321,11 +328,12 @@ struct Bug6_ScenarioCDateRangeTests {
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)")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty,
"Games spanning exactly daySpan should produce at least one option")
}
}
@@ -634,9 +642,11 @@ struct Bug13_MissingStadiumTests {
// Currently: silently excluded noGamesInRange.
// This test documents the current behavior (missing stadiums are excluded).
if case .failure(let failure) = result {
#expect(failure.reason == .noGamesInRange)
guard case .failure(let failure) = result else {
Issue.record("Expected .failure, got \(result)")
return
}
#expect(failure.reason == .noGamesInRange)
}
}
@@ -647,14 +657,7 @@ struct Bug13_MissingStadiumTests {
@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")
}
// Bug #14 (drag state) is a UI-layer issue tracked separately no unit test possible here.
}
// MARK: - Bug #15: ScenarioB force unwraps on date arithmetic
@@ -699,8 +702,14 @@ struct Bug15_DateArithmeticTests {
)
let planner = ScenarioBPlanner()
// Should not crash just verifying safety
let _ = planner.plan(request: request)
let result = planner.plan(request: request)
// Should not crash verify we get a valid result (success or failure, not a crash)
switch result {
case .success(let options):
#expect(!options.isEmpty, "If success, should have at least one option")
case .failure:
break // Failure is acceptable the point is it didn't crash
}
}
}
@@ -709,14 +718,7 @@ struct Bug15_DateArithmeticTests {
@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")
}
// Bug #16 (sortOrder accumulation) is in ItineraryReorderingLogic tracked separately.
}
// MARK: - Cross-cutting: TravelEstimator consistency

View File

@@ -131,13 +131,14 @@ struct ScenarioAPlannerTests {
// Should succeed with only NYC game (East coast)
guard case .success(let options) = result else {
// May fail for other reasons (no valid routes), but shouldn't include LA
Issue.record("Expected .success, got \(result)")
return
}
// If success, verify only East coast games included
// Verify only East coast games included
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("game-la"), "LA game should be filtered out by East region filter")
#expect(allGameIds.contains("game-nyc"), "NYC game should be included in East region filter")
}
// MARK: - Specification Tests: Must-Stop Filtering
@@ -174,13 +175,16 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request)
// If success, should only include NYC games
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("game-nyc"), "NYC game should be included")
// Boston game may or may not be included depending on route logic
// Should include NYC games
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let gameIds = Set(option.stops.flatMap { $0.games })
#expect(gameIds.contains("game-nyc"), "Every option must include NYC game (must-stop constraint)")
}
// Could also fail with noGamesInRange if must-stop filter is strict
}
@Test("plan: mustStopLocation with no games in that city returns noGamesInRange")
@@ -346,6 +350,7 @@ struct ScenarioAPlannerTests {
// Should have 1 stop with 2 games (not 2 stops)
let totalGamesInNYC = nycStops.flatMap { $0.games }.count
#expect(totalGamesInNYC >= 2, "Both games should be in the route")
#expect(nycStops.count == 1, "Two games at same stadium should create exactly one stop, got \(nycStops.count)")
}
}
@@ -379,11 +384,14 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("in-range"))
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("in-range"))
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
}
@Test("Invariant: A-B-A creates 3 stops not 2")
@@ -419,17 +427,19 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
// Look for an option that includes all 3 games
let optionWithAllGames = options.first { option in
let allGames = option.stops.flatMap { $0.games }
return allGames.contains("nyc1") && allGames.contains("boston1") && allGames.contains("nyc2")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
if let option = optionWithAllGames {
// NYC appears first and last, so should have at least 3 stops
#expect(option.stops.count >= 3, "A-B-A pattern should create 3 stops")
}
// Look for an option that includes all 3 games
let optionWithAllGames = options.first { option in
let ids = Set(option.stops.flatMap { $0.games })
return ids.contains("nyc1") && ids.contains("boston1") && ids.contains("nyc2")
}
#expect(optionWithAllGames != nil, "Should have at least one route containing all 3 games")
if let option = optionWithAllGames {
#expect(option.stops.count >= 3, "NYC-BOS-NYC pattern should create at least 3 stops")
}
}
@@ -462,11 +472,54 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Success must have at least one option")
for option in options {
#expect(!option.stops.isEmpty, "Each option must have at least one stop")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Success must have at least one option")
for option in options {
#expect(!option.stops.isEmpty, "Each option must have at least one stop")
}
}
// MARK: - Output Sanity
@Test("plan: game with missing stadium excluded, no crash")
func plan_missingStadiumForGame_gameExcluded() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let validGame = makeGame(id: "valid", stadiumId: "nyc", dateTime: TestClock.addingDays(2))
let orphanGame = Game(id: "orphan", homeTeamId: "t2", awayTeamId: "vis",
stadiumId: "no_such_stadium", dateTime: TestClock.addingDays(3),
sport: .mlb, season: "2026", isPlayoff: false)
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.addingDays(1),
endDate: TestClock.addingDays(7),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [validGame, orphanGame],
teams: [:],
stadiums: ["nyc": nycStadium]
)
let result = planner.plan(request: request)
// Primary assertion: no crash
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let ids = Set(option.stops.flatMap { $0.games })
#expect(!ids.contains("orphan"),
"Game with missing stadium should not appear in output")
}
}

View File

@@ -129,12 +129,15 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
let gameIds = option.stops.flatMap { $0.games }
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let gameIds = option.stops.flatMap { $0.games }
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
}
}
@@ -291,10 +294,12 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request)
// Should succeed even without explicit dates because of sliding window
if case .success(let options) = result {
#expect(!options.isEmpty)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// May also fail if no valid date ranges, which is acceptable
#expect(!options.isEmpty)
}
@Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
@@ -418,12 +423,15 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
let gameIds = Set(option.stops.flatMap { $0.games })
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let gameIds = Set(option.stops.flatMap { $0.games })
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
}
}
@@ -458,12 +466,47 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Success must have options")
for option in options {
let allGames = option.stops.flatMap { $0.games }
#expect(allGames.contains("anchor1"), "Every option must include anchor")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Success must have options")
for option in options {
let allGames = option.stops.flatMap { $0.games }
#expect(allGames.contains("anchor1"), "Every option must include anchor")
}
}
@Test("plan: anchor game in past — handled gracefully")
func plan_anchorGameInPast_handledGracefully() {
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let pastAnchor = makeGame(id: "past_anchor", stadiumId: "nyc", dateTime: TestClock.addingDays(-1))
let prefs = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: ["past_anchor"],
startDate: TestClock.addingDays(-3),
endDate: TestClock.addingDays(5),
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastAnchor],
teams: [:],
stadiums: ["nyc": nycStadium]
)
// Should not crash. May include past anchor (it's explicitly selected) or fail gracefully.
let result = planner.plan(request: request)
switch result {
case .success:
break
case .failure(let f):
Issue.record("Unexpected failure: \(f)")
}
}

View File

@@ -286,11 +286,13 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// LA game should NOT be in any route (wrong direction)
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// LA game should NOT be in any route (wrong direction)
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
}
// MARK: - Specification Tests: Start/End Stops
@@ -336,13 +338,15 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
// First stop should be start city
#expect(option.stops.first?.city == "Chicago", "First stop should be start city")
// Last stop should be end city
#expect(option.stops.last?.city == "New York", "Last stop should be end city")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
// First stop should be start city
#expect(option.stops.first?.city == "Chicago", "First stop should be start city")
// Last stop should be end city
#expect(option.stops.last?.city == "New York", "Last stop should be end city")
}
}
@@ -383,18 +387,18 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
let firstStop = option.stops.first
// The start stop (added as endpoint) should have no games
// Note: The first stop might be a game stop if start city has games
if firstStop?.city == "Chicago" && option.stops.count > 1 {
// If there's a separate start stop with no games, verify it
let stopsWithNoGames = option.stops.filter { $0.games.isEmpty }
// At minimum, there should be endpoint stops
#expect(stopsWithNoGames.count >= 0) // Just ensure no crash
}
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
// When start city (Chicago) has a game, the endpoint is merged into the game stop.
// Verify the first stop IS Chicago (either as game stop or endpoint).
#expect(option.stops.first?.city == "Chicago",
"First stop should be the start city (Chicago)")
// Verify the last stop is the end city
#expect(option.stops.last?.city == "New York",
"Last stop should be the end city (New York)")
}
}
@@ -433,24 +437,61 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
#expect(option.stops.last?.city == "New York", "End city must be last stop")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
#expect(option.stops.last?.city == "New York", "End city must be last stop")
}
}
// MARK: - Property Tests
@Test("Property: forward progress tolerance is 15%")
@Test("Property: forward progress tolerance filters distant backward stadiums")
func property_forwardProgressTolerance() {
// This tests the documented invariant that tolerance is 15%
// We verify by testing that a stadium 16% backward gets filtered
// vs one that is 14% backward gets included
// Chicago NYC route. LA is far backward (west), should be excluded.
// Cleveland is forward (east of Chicago, toward NYC), should be included.
let chicagoStad = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4958, longitude: -81.6853)
let clevelandStad = makeStadium(id: "cle", city: "Cleveland", coordinate: clevelandCoord)
let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400)
let laStad = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
// This is more of a documentation test - the actual tolerance is private
// We trust the implementation matches the documented behavior
#expect(true, "Forward progress tolerance documented as 15%")
let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1))
let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3))
let laGame = makeGame(id: "g_la", stadiumId: "la", dateTime: TestClock.addingDays(4))
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(6))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chiGame, cleGame, laGame, nycGame],
teams: [:],
stadiums: ["chi": chicagoStad, "nyc": nycStad, "cle": clevelandStad, "la": laStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let cities = option.stops.map(\.city)
#expect(!cities.contains("Los Angeles"),
"LA is far backward from Chicago→NYC route and should be excluded")
}
}
// MARK: - Regression Tests: Endpoint Merging
@@ -499,19 +540,21 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When the route includes a Houston game stop, there should NOT also be
// a separate empty Houston endpoint stop (the fix merges them)
let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" }
let emptyHoustonStops = houstonStops.filter { !$0.hasGames }
let gameHoustonStops = houstonStops.filter { $0.hasGames }
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When the route includes a Houston game stop, there should NOT also be
// a separate empty Houston endpoint stop (the fix merges them)
let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" }
let emptyHoustonStops = houstonStops.filter { !$0.hasGames }
let gameHoustonStops = houstonStops.filter { $0.hasGames }
if !gameHoustonStops.isEmpty {
#expect(emptyHoustonStops.isEmpty,
"Should not have both a game stop and empty endpoint in Houston")
}
if !gameHoustonStops.isEmpty {
#expect(emptyHoustonStops.isEmpty,
"Should not have both a game stop and empty endpoint in Houston")
}
}
}
@@ -557,22 +600,24 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When a route includes a game in an endpoint city,
// there should NOT also be a separate empty endpoint stop for that city
let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" }
if chicagoStops.contains(where: { $0.hasGames }) {
#expect(!chicagoStops.contains(where: { !$0.hasGames }),
"No redundant empty Chicago endpoint when game stop exists")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When a route includes a game in an endpoint city,
// there should NOT also be a separate empty endpoint stop for that city
let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" }
if chicagoStops.contains(where: { $0.hasGames }) {
#expect(!chicagoStops.contains(where: { !$0.hasGames }),
"No redundant empty Chicago endpoint when game stop exists")
}
let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" }
if nycStops.contains(where: { $0.hasGames }) {
#expect(!nycStops.contains(where: { !$0.hasGames }),
"No redundant empty NYC endpoint when game stop exists")
}
let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" }
if nycStops.contains(where: { $0.hasGames }) {
#expect(!nycStops.contains(where: { !$0.hasGames }),
"No redundant empty NYC endpoint when game stop exists")
}
}
}
@@ -622,21 +667,23 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty)
// For routes that include the Chicago game, the start endpoint
// should be merged (no separate empty Chicago stop).
// For routes that don't include the Chicago game, an empty
// Chicago endpoint is correctly added.
for option in options {
let chicagoStops = option.stops.filter { $0.city == "Chicago" }
let hasGameInChicago = chicagoStops.contains { $0.hasGames }
let hasEmptyChicago = chicagoStops.contains { !$0.hasGames }
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty)
// For routes that include the Chicago game, the start endpoint
// should be merged (no separate empty Chicago stop).
// For routes that don't include the Chicago game, an empty
// Chicago endpoint is correctly added.
for option in options {
let chicagoStops = option.stops.filter { $0.city == "Chicago" }
let hasGameInChicago = chicagoStops.contains { $0.hasGames }
let hasEmptyChicago = chicagoStops.contains { !$0.hasGames }
// Should never have BOTH an empty endpoint and a game stop for same city
#expect(!(hasGameInChicago && hasEmptyChicago),
"Should not have both game and empty stops for Chicago")
}
// Should never have BOTH an empty endpoint and a game stop for same city
#expect(!(hasGameInChicago && hasEmptyChicago),
"Should not have both game and empty stops for Chicago")
}
}
@@ -772,6 +819,115 @@ struct ScenarioCPlannerTests {
#expect(!options.isEmpty, "Should produce at least one itinerary")
}
// MARK: - Output Sanity
@Test("plan: all stops progress toward end location")
func plan_allStopsProgressTowardEnd() {
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
let phillyC = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
let phillyStad = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyC)
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
let games = [
makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)),
makeGame(id: "g_philly", stadiumId: "philly", dateTime: TestClock.addingDays(3)),
makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(5)),
makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)),
]
let startLoc = LocationInput(name: "New York", coordinate: nycC)
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLoc,
endLocation: endLoc,
sports: [.mlb],
startDate: TestClock.addingDays(0),
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [:],
stadiums: ["nyc": nycStad, "philly": phillyStad, "dc": dcStad, "atl": atlantaStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let gameStops = option.stops.filter { !$0.games.isEmpty }
for i in 0..<(gameStops.count - 1) {
if let coord1 = gameStops[i].coordinate, let coord2 = gameStops[i + 1].coordinate {
let progressing = coord2.latitude <= coord1.latitude + 2.0
#expect(progressing,
"Stops should progress toward Atlanta (south): \(gameStops[i].city)\(gameStops[i+1].city)")
}
}
}
}
@Test("plan: games outside directional cone excluded")
func plan_gamesOutsideDirectionalCone_excluded() {
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
let bostonC = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
let bostonStad = makeStadium(id: "boston", city: "Boston", coordinate: bostonC)
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1))
let atlGame = makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7))
let bostonGame = makeGame(id: "g_boston", stadiumId: "boston", dateTime: TestClock.addingDays(3))
let dcGame = makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(4))
let startLoc = LocationInput(name: "New York", coordinate: nycC)
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLoc,
endLocation: endLoc,
sports: [.mlb],
startDate: TestClock.addingDays(0),
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [nycGame, atlGame, bostonGame, dcGame],
teams: [:],
stadiums: ["nyc": nycStad, "atl": atlantaStad, "boston": bostonStad, "dc": dcStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let cities = option.stops.map(\.city)
#expect(!cities.contains("Boston"),
"Boston (north of NYC) should be excluded when traveling NYC→Atlanta")
}
}
// MARK: - Helper Methods
private func makeStadium(

View File

@@ -19,6 +19,7 @@ struct ScenarioDPlannerTests {
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
// MARK: - Specification Tests: Missing Team
@@ -155,13 +156,16 @@ struct ScenarioDPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// Both home and away games should be includable
let hasHomeGame = allGameIds.contains("home-game")
let hasAwayGame = allGameIds.contains("away-game")
#expect(hasHomeGame || hasAwayGame, "Should include at least one team game")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// At least one option should include BOTH home and away games
let hasOptionWithBoth = options.contains { option in
let gameIds = Set(option.stops.flatMap { $0.games })
return gameIds.contains("home-game") && gameIds.contains("away-game")
}
#expect(hasOptionWithBoth, "At least one option should include both home and away games")
}
// MARK: - Specification Tests: Region Filtering
@@ -219,10 +223,13 @@ struct ScenarioDPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("east-game"), "East game should be included when East region is selected")
#expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region")
}
// MARK: - Specification Tests: Successful Planning
@@ -277,8 +284,8 @@ struct ScenarioDPlannerTests {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let homeCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) // Denver
let homeLocation = LocationInput(name: "Denver", coordinate: homeCoord)
let homeCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // NYC
let homeLocation = LocationInput(name: "New York", coordinate: homeCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let game = Game(
@@ -322,8 +329,8 @@ struct ScenarioDPlannerTests {
#expect(!options.isEmpty)
for option in options {
#expect(option.stops.first?.city == "Denver")
#expect(option.stops.last?.city == "Denver")
#expect(option.stops.first?.city == "New York")
#expect(option.stops.last?.city == "New York")
#expect(option.stops.first?.games.isEmpty == true)
#expect(option.stops.last?.games.isEmpty == true)
}
@@ -396,29 +403,70 @@ struct ScenarioDPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
// Full invariant: ALL returned games must involve the followed team
let allGames = [homeGame, awayGame, otherGame]
for gameId in allGameIds {
let game = allGames.first { $0.id == gameId }
#expect(game != nil, "Game ID \(gameId) should be in the available games list")
if let game = game {
#expect(game.homeTeamId == teamId || game.awayTeamId == teamId,
"Game \(gameId) should involve followed team \(teamId)")
}
}
}
@Test("Invariant: duplicate routes are removed")
func invariant_duplicateRoutesRemoved() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 7)
let endDate = startDate.addingTimeInterval(86400 * 14)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
let game = Game(
id: "game1",
// 3 games for the followed team at nearby cities the DAG router may
// produce multiple routes (e.g. [NYC, BOS], [NYC, PHI], [NYC, BOS, PHI])
// which makes the uniqueness check meaningful.
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
let game1 = Game(
id: "game-nyc",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "stadium1",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let game2 = Game(
id: "game-bos",
homeTeamId: "red-sox",
awayTeamId: "yankees",
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 5),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let game3 = Game(
id: "game-phi",
homeTeamId: "phillies",
awayTeamId: "yankees",
stadiumId: "philly",
dateTime: startDate.addingTimeInterval(86400 * 8),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
@@ -426,27 +474,29 @@ struct ScenarioDPlannerTests {
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
numberOfDrivers: 2,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
availableGames: [game1, game2, game3],
teams: [:],
stadiums: ["stadium1": stadium]
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
)
let result = planner.plan(request: request)
if case .success(let options) = result {
// Verify no duplicate game combinations
var seenGameCombinations = Set<String>()
for option in options {
let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
#expect(!seenGameCombinations.contains(gameIds), "Duplicate route found: \(gameIds)")
seenGameCombinations.insert(gameIds)
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// Verify no two options have identical game-ID sets
var seenGameCombinations = Set<String>()
for option in options {
let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
#expect(!seenGameCombinations.contains(gameIds), "Duplicate route found: \(gameIds)")
seenGameCombinations.insert(gameIds)
}
}
@@ -489,11 +539,13 @@ struct ScenarioDPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Success must have at least one option")
for option in options {
#expect(!option.stops.isEmpty, "Each option must have stops")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Success must have at least one option")
for option in options {
#expect(!option.stops.isEmpty, "Each option must have stops")
}
}

View File

@@ -548,41 +548,47 @@ struct ScenarioEPlannerTests {
@Test("plan: routes sorted by duration ascending")
func plan_routesSortedByDurationAscending() {
let baseDate = TestClock.now
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create multiple windows with different durations
// Window 1: Games on day 1 and 2 (shorter trip)
// Window 2: Games on day 10 and 14 (longer trip within window)
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
// Window 1: Games on day 1 and day 4 (tighter)
// Window 2: Games on day 10 and day 13 (separate window)
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let day10Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 10))!
let day13Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 13))!
let yankeesGame1 = makeGame(
id: "yankees-1",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
dateTime: day1Evening
)
let redsoxGame1 = makeGame(
id: "redsox-1",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 2)
dateTime: day4Evening
)
let yankeesGame2 = makeGame(
id: "yankees-2",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 10)
dateTime: day10Evening
)
let redsoxGame2 = makeGame(
id: "redsox-2",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 12)
dateTime: day13Evening
)
let prefs = TripPreferences(
@@ -617,30 +623,43 @@ struct ScenarioEPlannerTests {
for (index, option) in options.enumerated() {
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...")
}
// Verify actual duration ordering: each option's trip duration <= next option's
for i in 0..<(options.count - 1) {
let daysA = Calendar.current.dateComponents([.day], from: options[i].stops.first!.arrivalDate, to: options[i].stops.last!.departureDate).day ?? 0
let daysB = Calendar.current.dateComponents([.day], from: options[i+1].stops.first!.arrivalDate, to: options[i+1].stops.last!.departureDate).day ?? 0
#expect(daysA <= daysB, "Option \(i) duration \(daysA)d should be <= option \(i+1) duration \(daysB)d")
}
}
@Test("plan: respects max driving time constraint")
func plan_respectsMaxDrivingTimeConstraint() {
let baseDate = TestClock.now
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
// NYC and LA are ~40 hours apart by car
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
// Games on consecutive days - impossible to drive between
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
// Spread games apart so the window generator produces a valid window,
// but keep them on opposite coasts so the driving constraint rejects the route.
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day5Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 5))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
dateTime: day1Evening
)
let dodgersGame = makeGame(
id: "dodgers-home",
homeTeamId: "dodgers",
awayTeamId: "opponent",
stadiumId: "la",
dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible
dateTime: day5Evening
)
let prefs = TripPreferences(
@@ -650,7 +669,7 @@ struct ScenarioEPlannerTests {
endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1, // Single driver, 8 hours max
numberOfDrivers: 1, // Single driver, 8 hours max impossible NYCLA
selectedTeamIds: ["yankees", "dodgers"]
)
@@ -989,31 +1008,39 @@ struct ScenarioEPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
#expect(options.count <= 10, "Should return at most 10 results")
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(options.count <= 10, "Should return at most 10 results")
}
@Test("Invariant: all routes contain home games from all selected teams")
func invariant_allRoutesContainAllSelectedTeams() {
let baseDate = TestClock.now
let calendar = TestClock.calendar
let baseDate = calendar.startOfDay(for: TestClock.now)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
let day1Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 1))!
let day4Evening = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: baseDate.addingTimeInterval(86400 * 4))!
let yankeesGame = makeGame(
id: "yankees-home",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1)
dateTime: day1Evening
)
let redsoxGame = makeGame(
id: "redsox-home",
homeTeamId: "redsox",
awayTeamId: "opponent",
stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 2)
dateTime: day4Evening
)
let prefs = TripPreferences(
@@ -1039,22 +1066,25 @@ struct ScenarioEPlannerTests {
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games })
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// At minimum, should have one game per selected team
let hasYankeesGame = allGameIds.contains { gameId in
// Check if any game in this route is a Yankees home game
request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees"
}
let hasRedsoxGame = allGameIds.contains { gameId in
request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox"
}
for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games })
#expect(hasYankeesGame, "Every route must include a Yankees home game")
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
// At minimum, should have one game per selected team
let hasYankeesGame = allGameIds.contains { gameId in
// Check if any game in this route is a Yankees home game
request.availableGames.first { $0.id == gameId }?.homeTeamId == "yankees"
}
let hasRedsoxGame = allGameIds.contains { gameId in
request.availableGames.first { $0.id == gameId }?.homeTeamId == "redsox"
}
#expect(hasYankeesGame, "Every route must include a Yankees home game")
#expect(hasRedsoxGame, "Every route must include a Red Sox home game")
}
}
@@ -1096,50 +1126,58 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// Should succeed both teams have east coast games
if case .success(let options) = result {
// Should succeed both teams have east coast games.
// Failure is also acceptable if routing constraints prevent a valid route.
switch result {
case .success(let options):
for option in options {
let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA")
}
case .failure:
break // Acceptable routing constraints may prevent a valid route
}
// If it fails, that's also acceptable since routing may not work out
}
@Test("teamFirst: all regions includes everything")
func teamFirst_allRegions_includesEverything() {
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York")
let teamLA = TestFixtures.team(id: "team_la", city: "Los Angeles")
let teamBOS = TestFixtures.team(id: "team_bos", city: "Boston")
let stadiumNYC = TestFixtures.stadium(id: "stadium_nyc", city: "New York")
let stadiumLA = TestFixtures.stadium(id: "stadium_la", city: "Los Angeles")
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let day5 = TestClock.calendar.date(byAdding: .day, value: 4, to: baseDate)!
// 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
let gameLA = TestFixtures.game(id: "game_la", city: "Los Angeles", dateTime: day5, homeTeamId: "team_la", stadiumId: "stadium_la")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [.east, .central, .west], // All regions
selectedTeamIds: ["team_nyc", "team_la"]
selectedTeamIds: ["team_nyc", "team_bos"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [gameNYC, gameLA],
teams: ["team_nyc": teamNYC, "team_la": teamLA],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_la": stadiumLA]
availableGames: [gameNYC, gameBOS],
teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
)
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// With all regions, both games should be available
// (may still fail due to driving constraints, but games won't be region-filtered)
#expect(result.isSuccess || result.failure?.reason == .constraintsUnsatisfiable || result.failure?.reason == .noValidRoutes)
// With all regions and nearby east-coast cities, planning should succeed
guard case .success(let options) = result else {
Issue.record("Expected .success with all regions and feasible route, got \(result)")
return
}
#expect(!options.isEmpty, "Should return at least one route option")
}
@Test("teamFirst: empty regions includes everything")
@@ -1151,14 +1189,16 @@ struct ScenarioEPlannerTests {
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
// 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day4 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let gameNYC = TestFixtures.game(id: "game_nyc", city: "New York", dateTime: baseDate, homeTeamId: "team_nyc", stadiumId: "stadium_nyc")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day2, homeTeamId: "team_bos", stadiumId: "stadium_bos")
let gameBOS = TestFixtures.game(id: "game_bos", city: "Boston", dateTime: day4, homeTeamId: "team_bos", stadiumId: "stadium_bos")
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [], // Empty = no filtering
selectedTeamIds: ["team_nyc", "team_bos"]
)
@@ -1173,8 +1213,12 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner()
let result = planner.plan(request: request)
// Empty regions = no filtering, so both games should be available
#expect(result.isSuccess || result.failure?.reason != .noGamesInRange)
// Empty regions = no filtering, so both games should be available and route feasible
guard case .success(let options) = result else {
Issue.record("Expected .success with empty regions (no filtering) and feasible route, got \(result)")
return
}
#expect(!options.isEmpty, "Should return at least one route option")
}
// MARK: - Past Date Filtering Tests
@@ -1256,16 +1300,18 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
if case .success(let options) = result {
// All returned stops should be on or after currentDate
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
"All stops should be in the future, got \(stop.arrivalDate)")
}
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// All returned stops should be on or after currentDate
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= calendar.startOfDay(for: currentDate),
"All stops should be in the future, got \(stop.arrivalDate)")
}
}
// Failure is acceptable if routing constraints prevent a valid route
}
@Test("teamFirst: evaluates all sampled windows across full season")
@@ -1328,6 +1374,165 @@ struct ScenarioEPlannerTests {
#expect(months.count >= 2, "Results should span at least 2 months, got months: \(months.sorted())")
}
// MARK: - Output Sanity
@Test("plan: all stop dates in the future (synthetic regression)")
func plan_allStopDatesInFuture_syntheticRegression() {
// Regression for the PHI/WSN/BAL bug: past spring training games in output
let currentDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12)
let calendar = TestClock.calendar
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Mix of past and future games
let pastGame1 = makeGame(id: "past-nyc", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 10, hour: 13))
let pastGame2 = makeGame(id: "past-bos", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 3, day: 12, hour: 13))
let futureGame1 = makeGame(id: "future-nyc", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestFixtures.date(year: 2026, month: 6, day: 5, hour: 19))
let futureGame2 = makeGame(id: "future-bos", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestFixtures.date(year: 2026, month: 6, day: 7, hour: 19))
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [pastGame1, pastGame2, futureGame1, futureGame2],
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let startOfDay = calendar.startOfDay(for: currentDate)
for option in options {
for stop in option.stops {
#expect(stop.arrivalDate >= startOfDay,
"Stop on \(stop.arrivalDate) is before currentDate \(startOfDay)")
}
}
}
@Test("plan: results cover multiple months when games spread across season")
func plan_resultsCoverMultipleMonths() {
let currentDate = TestFixtures.date(year: 2026, month: 4, day: 1, hour: 12)
let calendar = TestClock.calendar
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
var games: [Game] = []
for month in 4...9 {
let dt1 = TestFixtures.date(year: 2026, month: month, day: 5, hour: 19)
let dt2 = TestFixtures.date(year: 2026, month: month, day: 7, hour: 19)
games.append(makeGame(id: "nyc-\(month)", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc", dateTime: dt1))
games.append(makeGame(id: "bos-\(month)", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston", dateTime: dt2))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(options.count >= 2, "Should have multiple options across season")
let months = Set(options.flatMap { opt in
opt.stops.map { calendar.component(.month, from: $0.arrivalDate) }
})
#expect(months.count >= 2,
"Results should span multiple months, got: \(months.sorted())")
}
@Test("plan: every option has all selected teams")
func plan_everyOptionHasAllSelectedTeams_tighter() {
let currentDate = TestClock.now
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
var games: [Game] = []
for day in stride(from: 1, through: 30, by: 3) {
games.append(makeGame(id: "nyc-\(day)", homeTeamId: "yankees", awayTeamId: "opp",
stadiumId: "nyc",
dateTime: TestClock.addingDays(day)))
games.append(makeGame(id: "bos-\(day)", homeTeamId: "redsox", awayTeamId: "opp",
stadiumId: "boston",
dateTime: TestClock.addingDays(day + 1)))
}
let prefs = TripPreferences(
planningMode: .teamFirst,
sports: [.mlb],
numberOfDrivers: 2,
selectedTeamIds: ["yankees", "redsox"]
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: ["yankees": makeTeam(id: "yankees", name: "Yankees"),
"redsox": makeTeam(id: "redsox", name: "Red Sox")],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let gameMap = Dictionary(games.map { ($0.id, $0) }, uniquingKeysWith: { f, _ in f })
let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for (idx, option) in options.enumerated() {
let homeTeams = Set(
option.stops.flatMap { $0.games }
.compactMap { gameMap[$0]?.homeTeamId }
)
#expect(homeTeams.contains("yankees"),
"Option \(idx): missing Yankees home game")
#expect(homeTeams.contains("redsox"),
"Option \(idx): missing Red Sox home game")
}
}
// MARK: - Helper Methods
private func makeStadium(

View File

@@ -278,15 +278,14 @@ struct TravelIntegrity_EngineGateTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for (i, option) in options.enumerated() {
#expect(option.isValid,
"Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID")
// Double-check the math
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
}
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (i, option) in options.enumerated() {
#expect(option.isValid,
"Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID")
// Double-check the math
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Option \(i): \(option.stops.count) stops must have \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
}
}
}
@@ -624,19 +623,18 @@ struct TravelIntegrity_EdgeCaseTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
for i in 0..<option.travelSegments.count {
let segment = option.travelSegments[i]
let fromStop = option.stops[i]
let toStop = option.stops[i + 1]
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
for i in 0..<option.travelSegments.count {
let segment = option.travelSegments[i]
let fromStop = option.stops[i]
let toStop = option.stops[i + 1]
// Segment endpoints should match stop cities
#expect(segment.fromLocation.name == fromStop.city,
"Segment \(i) from '\(segment.fromLocation.name)' should match stop '\(fromStop.city)'")
#expect(segment.toLocation.name == toStop.city,
"Segment \(i) to '\(segment.toLocation.name)' should match stop '\(toStop.city)'")
}
// Segment endpoints should match stop cities
#expect(segment.fromLocation.name == fromStop.city,
"Segment \(i) from '\(segment.fromLocation.name)' should match stop '\(fromStop.city)'")
#expect(segment.toLocation.name == toStop.city,
"Segment \(i) to '\(segment.toLocation.name)' should match stop '\(toStop.city)'")
}
}
}

View File

@@ -20,48 +20,6 @@ struct TripPlanningEngineTests {
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
// MARK: - Specification Tests: Planning Mode Selection
@Test("planningMode: dateRange is valid mode")
func planningMode_dateRange() {
let prefs = TripPreferences(
planningMode: .dateRange,
sports: [.mlb]
)
#expect(prefs.planningMode == .dateRange)
}
@Test("planningMode: gameFirst is valid mode")
func planningMode_gameFirst() {
let prefs = TripPreferences(
planningMode: .gameFirst,
sports: [.mlb],
mustSeeGameIds: ["game1"]
)
#expect(prefs.planningMode == .gameFirst)
}
@Test("planningMode: followTeam is valid mode")
func planningMode_followTeam() {
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
followTeamId: "yankees"
)
#expect(prefs.planningMode == .followTeam)
}
@Test("planningMode: locations is valid mode")
func planningMode_locations() {
let prefs = TripPreferences(
planningMode: .locations,
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb]
)
#expect(prefs.planningMode == .locations)
}
// MARK: - Specification Tests: Driving Constraints
@Test("DrivingConstraints: calculates maxDailyDrivingHours correctly")
@@ -177,12 +135,11 @@ struct TripPlanningEngineTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
for option in options {
#expect(option.isValid, "Every returned option must be valid (segments = stops - 1)")
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1)
}
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options {
#expect(option.isValid, "Every returned option must be valid (segments = stops - 1)")
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1)
}
}
}
@@ -219,16 +176,15 @@ struct TripPlanningEngineTests {
let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request)
if case .success(let options) = result {
#expect(!options.isEmpty, "Should produce at least one option")
for option in options {
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
} else {
#expect(option.travelSegments.isEmpty,
"Single-stop option must have 0 segments")
}
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty, "Should produce at least one option")
for option in options {
if option.stops.count > 1 {
#expect(option.travelSegments.count == option.stops.count - 1,
"Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
} else {
#expect(option.travelSegments.isEmpty,
"Single-stop option must have 0 segments")
}
}
}