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 centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"]
static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"] 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,19 +150,13 @@ struct Phase1B_ScenarioERegionTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
// With only East region, LA team has no home games should fail // With only East region, LA team has no home games should fail
if case .failure(let failure) = result { guard case .failure(let failure) = result else {
Issue.record("Expected .failure, got \(result)")
return
}
#expect(failure.reason == .noGamesInRange, #expect(failure.reason == .noGamesInRange,
"Should fail because LA team has no East region games") "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")
}
}
}
@Test("ScenarioE with all regions includes all teams") @Test("ScenarioE with all regions includes all teams")
func scenarioE_allRegions_includesAll() { func scenarioE_allRegions_includesAll() {
@@ -204,11 +198,14 @@ struct Phase1B_ScenarioERegionTests {
let planner = ScenarioEPlanner() let planner = ScenarioEPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should succeed with both nearby East Coast teams // Should succeed with both nearby East Coast teams.
if case .success(let options) = result { // Failure is also OK if driving constraints prevent it.
switch result {
case .success(let options):
#expect(!options.isEmpty, "Should find routes for NYC + Boston") #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,14 +243,16 @@ struct Phase1C_MustStopTests {
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// If successful, all options must include Boston // If successful, all options must include Boston
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "All options must include Boston must-stop") #expect(cities.contains("boston"), "All options must include Boston must-stop")
} }
} }
} }
}
// MARK: - Phase 1D: Travel Segment Validation // MARK: - Phase 1D: Travel Segment Validation
@@ -330,13 +329,15 @@ struct Phase1D_TravelSegmentTests {
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// If successful, all returned options must be valid // If successful, all returned options must be valid
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
#expect(option.isValid, "Engine should only return valid options") #expect(option.isValid, "Engine should only return valid options")
} }
} }
} }
}
// MARK: - Phase 2A: TravelEstimator returns nil on missing coordinates // MARK: - Phase 2A: TravelEstimator returns nil on missing coordinates
@@ -553,7 +554,7 @@ struct Phase2C_OvernightRestTests {
@Suite("Phase 2D: Silent Exclusion Warnings") @Suite("Phase 2D: Silent Exclusion Warnings")
struct Phase2D_ExclusionWarningTests { 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() { func engine_tracksRepeatCityWarnings() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19) let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19)
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
@@ -580,11 +581,24 @@ struct Phase2D_ExclusionWarningTests {
) )
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
_ = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// Engine should have warnings accessible (even if result is failure) // With allowRepeatCities=false, engine should return only routes without repeat cities
// The warnings property exists and is populated guard case .success(let options) = result else {
#expect(engine.warnings is [ConstraintViolation], "Warnings should be an array of ConstraintViolation") // 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") @Test("Engine tracks must-stop exclusion warnings")
@@ -615,11 +629,13 @@ struct Phase2D_ExclusionWarningTests {
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// Should fail with must-stop violation // Should fail with must-stop violation
if case .failure(let failure) = result { guard case .failure(let failure) = result else {
Issue.record("Expected .failure, got \(result)")
return
}
let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop }) let hasMustStopViolation = failure.violations.contains(where: { $0.type == .mustStop })
#expect(hasMustStopViolation, "Failure should include mustStop constraint violation") #expect(hasMustStopViolation, "Failure should include mustStop constraint violation")
} }
}
@Test("Engine tracks segment validation warnings") @Test("Engine tracks segment validation warnings")
func engine_tracksSegmentWarnings() { func engine_tracksSegmentWarnings() {
@@ -636,10 +652,15 @@ struct Phase2D_ExclusionWarningTests {
let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:]) let request = PlanningRequest(preferences: prefs, availableGames: [], teams: [:], stadiums: [:])
_ = engine.planItineraries(request: request) _ = 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) _ = engine.planItineraries(request: request)
let warningsAfterSecond = engine.warnings
// Warnings from first run should not leak into second run // 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 engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .failure(let failure) = result { guard case .failure(let failure) = result else {
Issue.record("Expected .failure, got \(result)")
return
}
#expect(failure.reason == .missingDateRange, #expect(failure.reason == .missingDateRange,
"Inverted date range should return missingDateRange failure") "Inverted date range should return missingDateRange failure")
#expect(failure.violations.contains(where: { $0.type == .dateRange }), #expect(failure.violations.contains(where: { $0.type == .dateRange }),
"Should include dateRange violation") "Should include dateRange violation")
} else {
#expect(Bool(false), "Inverted date range should not succeed")
}
} }
} }
@@ -945,7 +966,10 @@ struct Phase4D_CrossCountryTests {
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// If successful, should NOT contain LA // If successful, should NOT contain LA
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let cities = option.stops.map { $0.city } let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"), #expect(!cities.contains("Los Angeles"),
@@ -953,4 +977,3 @@ struct Phase4D_CrossCountryTests {
} }
} }
} }
}

View File

@@ -44,13 +44,12 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Every route must include Boston as a must-stop") #expect(cities.contains("boston"), "Every route must include Boston as a must-stop")
} }
} }
}
@Test("must stop impossible city returns failure") @Test("must stop impossible city returns failure")
func mustStops_impossibleCity_returnsFailure() { func mustStops_impossibleCity_returnsFailure() {
@@ -116,13 +115,12 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included") #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included")
} }
} }
}
@Test("scenarioD: must stops enforced via centralized filter") @Test("scenarioD: must stops enforced via centralized filter")
func scenarioD_mustStops_routesContainRequiredCities() { func scenarioD_mustStops_routesContainRequiredCities() {
@@ -156,41 +154,39 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode") #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in follow-team mode")
} }
} }
}
@Test("scenarioE: must stops enforced via centralized filter") @Test("scenarioE: must stops enforced via centralized filter")
func scenarioE_mustStops_routesContainRequiredCities() { func scenarioE_mustStops_routesContainRequiredCities() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) 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)! // 2 teams windowDuration = 4 days. Games must be within 3 days to fit in a single window.
let day2 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! let day3 = TestClock.calendar.date(byAdding: .day, value: 3, to: baseDate)!
let teamNYC = "team_mlb_new_york" let teamNYC = "team_mlb_new_york"
let teamBOS = "team_mlb_boston" let teamBOS = "team_mlb_boston"
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamNYC) let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate, homeTeamId: teamNYC)
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day1, homeTeamId: teamBOS) let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3, homeTeamId: teamBOS)
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2, homeTeamId: "team_mlb_philadelphia")
let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS, gamePHL]) let stadiums = TestFixtures.stadiumMap(for: [gameNYC, gameBOS])
let prefs = TripPreferences( let prefs = TripPreferences(
planningMode: .teamFirst, planningMode: .teamFirst,
sports: [.mlb], sports: [.mlb],
startDate: baseDate, startDate: baseDate,
endDate: day2, endDate: day3,
mustStopLocations: [LocationInput(name: "Boston")], mustStopLocations: [LocationInput(name: "Boston")],
selectedTeamIds: [teamNYC, teamBOS] selectedTeamIds: [teamNYC, teamBOS]
) )
let request = PlanningRequest( let request = PlanningRequest(
preferences: prefs, preferences: prefs,
availableGames: [gameNYC, gameBOS, gamePHL], availableGames: [gameNYC, gameBOS],
teams: [ teams: [
teamNYC: TestFixtures.team(city: "New York"), teamNYC: TestFixtures.team(city: "New York"),
teamBOS: TestFixtures.team(city: "Boston"), teamBOS: TestFixtures.team(city: "Boston"),
@@ -201,54 +197,58 @@ struct MustStopValidationTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode") #expect(cities.contains("boston"), "Must-stop filter should ensure Boston is included in team-first mode")
} }
} }
}
@Test("scenarioC: must stops enforced via centralized filter") @Test("scenarioC: must stops enforced via centralized filter")
func scenarioC_mustStops_routesContainRequiredCities() { func scenarioC_mustStops_routesContainRequiredCities() {
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) // Route: Chicago New York (eastward). Detroit is directionally between them.
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)! let baseDate = TestClock.now
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)! 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 nycCoord = TestFixtures.coordinates["New York"]!
let bosCoord = TestFixtures.coordinates["Boston"]!
let gameNYC = TestFixtures.game(city: "New York", dateTime: baseDate) let chiStadium = TestFixtures.stadium(id: "chi", city: "Chicago")
let gamePHL = TestFixtures.game(city: "Philadelphia", dateTime: day2) let detStadium = TestFixtures.stadium(id: "det", city: "Detroit")
let gameBOS = TestFixtures.game(city: "Boston", dateTime: day3) 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( let prefs = TripPreferences(
planningMode: .locations, planningMode: .locations,
startLocation: LocationInput(name: "New York", coordinate: nycCoord), startLocation: LocationInput(name: "Chicago", coordinate: chiCoord),
endLocation: LocationInput(name: "Boston", coordinate: bosCoord), endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb], sports: [.mlb],
startDate: baseDate, startDate: baseDate,
endDate: day3, endDate: endDate,
mustStopLocations: [LocationInput(name: "Philadelphia")] leisureLevel: .moderate,
mustStopLocations: [LocationInput(name: "Detroit")],
lodgingType: .hotel,
numberOfDrivers: 2
) )
let request = PlanningRequest( let request = PlanningRequest(
preferences: prefs, preferences: prefs,
availableGames: [gameNYC, gamePHL, gameBOS], availableGames: [gameCHI, gameDET, gameNYC],
teams: [:], teams: [:],
stadiums: stadiums stadiums: ["chi": chiStadium, "det": detStadium, "nyc": nycStadium]
) )
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
let cities = option.stops.map { $0.city.lowercased() } let cities = option.stops.map { $0.city.lowercased() }
#expect(cities.contains("philadelphia"), "Must-stop filter should ensure Philadelphia is included in route mode") #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 engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) 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 // Should get either repeatCityViolation or noGamesInRange/noValidRoutes
let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"]) let isExpectedFailure = failure.reason == .repeatCityViolation(cities: ["New York"])
|| failure.reason == .noValidRoutes || failure.reason == .noValidRoutes
|| failure.reason == .noGamesInRange || failure.reason == .noGamesInRange
#expect(isExpectedFailure, "Should get a clear failure reason, got: \(failure.message)") #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") @Test("Must-stop filter with impossible city → clear error")
@@ -235,12 +237,10 @@ struct FilterCascadeTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .failure(let failure) = result { guard case .failure(let failure) = result else { Issue.record("Expected .failure, got \(result)"); return }
#expect(failure.violations.contains(where: { $0.type == .mustStop }), #expect(failure.violations.contains(where: { $0.type == .mustStop }),
"Should have mustStop violation") "Should have mustStop violation")
} }
// If no routes generated at all (noGamesInRange), that's also an acceptable failure
}
@Test("Empty sports set produces warning") @Test("Empty sports set produces warning")
func emptySportsSet_producesWarning() { func emptySportsSet_producesWarning() {
@@ -297,11 +297,34 @@ struct FilterCascadeTests {
geographicRationale: "test" geographicRationale: "test"
) )
// Case 1: No repeat cities filter is a no-op
let options = [option] let options = [option]
let once = RouteFilters.filterRepeatCities(options, allow: false) let once = RouteFilters.filterRepeatCities(options, allow: false)
let twice = RouteFilters.filterRepeatCities(once, 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 == 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 // Engine should handle this gracefully either find a route that visits NYC once
// or return a clear failure // 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"]) let hasReason = failure.reason == .repeatCityViolation(cities: ["New York"])
|| failure.reason == .noValidRoutes || failure.reason == .noValidRoutes
|| failure.reason == .noGamesInRange || failure.reason == .noGamesInRange
#expect(hasReason, "Should fail with a clear reason") #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") @Test("Multiple drivers extend feasible distance")
@@ -513,11 +538,23 @@ struct ConstraintInteractionTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
// Whether success or failure, warnings should be accessible // With must-stop NYC, some routes may be filtered. Verify:
// If options were filtered, we should see warnings // 1. The warnings property is accessible (doesn't crash)
if result.isSuccess && !engine.warnings.isEmpty { // 2. If warnings exist, they are all severity .warning
#expect(engine.warnings.allSatisfy { $0.severity == .warning }, let warnings = engine.warnings
for warning in warnings {
#expect(warning.severity == .warning,
"Exclusion notices should be warnings, not errors") "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,7 +212,10 @@ struct Bug4_ScenarioDRationaleTests {
let planner = ScenarioDPlanner() let planner = ScenarioDPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { 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. // Bug #4: rationale was using stops.count instead of actual game count.
// Verify that for each option, the game count in the rationale matches // Verify that for each option, the game count in the rationale matches
// the actual total games across stops. // the actual total games across stops.
@@ -223,8 +226,6 @@ struct Bug4_ScenarioDRationaleTests {
"Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)") "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 // MARK: - Bug #5: ScenarioD departureDate not advanced
@@ -261,7 +262,14 @@ struct Bug5_ScenarioDDepartureDateTests {
let planner = ScenarioDPlanner() let planner = ScenarioDPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result, let option = options.first { 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) // Find the game stop (not the home start/end waypoints)
let gameStops = option.stops.filter { $0.hasGames } let gameStops = option.stops.filter { $0.hasGames }
if let gameStop = gameStops.first { if let gameStop = gameStops.first {
@@ -272,7 +280,6 @@ struct Bug5_ScenarioDDepartureDateTests {
} }
} }
} }
}
// MARK: - Bug #6: ScenarioC date range off-by-one // MARK: - Bug #6: ScenarioC date range off-by-one
@@ -321,11 +328,12 @@ struct Bug6_ScenarioCDateRangeTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should find at least one option games exactly span the trip duration // Should find at least one option games exactly span the trip duration
if case .failure(let failure) = result { guard case .success(let options) = result else {
let reason = failure.reason Issue.record("Expected .success, got \(result)")
#expect(reason != PlanningFailure.FailureReason.noGamesInRange, return
"Games spanning exactly daySpan should not be excluded. Failure: \(failure.message)")
} }
#expect(!options.isEmpty,
"Games spanning exactly daySpan should produce at least one option")
} }
} }
@@ -634,9 +642,11 @@ struct Bug13_MissingStadiumTests {
// Currently: silently excluded noGamesInRange. // Currently: silently excluded noGamesInRange.
// This test documents the current behavior (missing stadiums are excluded). // This test documents the current behavior (missing stadiums are excluded).
if case .failure(let failure) = result { guard case .failure(let failure) = result else {
#expect(failure.reason == .noGamesInRange) Issue.record("Expected .failure, got \(result)")
return
} }
#expect(failure.reason == .noGamesInRange)
} }
} }
@@ -647,14 +657,7 @@ struct Bug13_MissingStadiumTests {
@Suite("Bug #14: Drag drop feedback") @Suite("Bug #14: Drag drop feedback")
struct Bug14_DragDropTests { struct Bug14_DragDropTests {
@Test("documented: drag state should not be cleared before validation") // Bug #14 (drag state) is a UI-layer issue tracked separately no unit test possible here.
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 // MARK: - Bug #15: ScenarioB force unwraps on date arithmetic
@@ -699,8 +702,14 @@ struct Bug15_DateArithmeticTests {
) )
let planner = ScenarioBPlanner() let planner = ScenarioBPlanner()
// Should not crash just verifying safety let result = planner.plan(request: request)
let _ = 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") @Suite("Bug #16: Sort order accumulation")
struct Bug16_SortOrderTests { struct Bug16_SortOrderTests {
@Test("documented: repeated before-games moves should use midpoint not subtraction") // Bug #16 (sortOrder accumulation) is in ItineraryReorderingLogic tracked separately.
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 // MARK: - Cross-cutting: TravelEstimator consistency

View File

@@ -131,13 +131,14 @@ struct ScenarioAPlannerTests {
// Should succeed with only NYC game (East coast) // Should succeed with only NYC game (East coast)
guard case .success(let options) = result else { 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 return
} }
// If success, verify only East coast games included // Verify only East coast games included
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } 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-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 // MARK: - Specification Tests: Must-Stop Filtering
@@ -174,13 +175,16 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
// If success, should only include NYC games // Should include NYC games
if case .success(let options) = result { guard case .success(let options) = result else {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } Issue.record("Expected .success, got \(result)")
#expect(allGameIds.contains("game-nyc"), "NYC game should be included") return
// Boston game may or may not be included depending on route logic }
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") @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) // Should have 1 stop with 2 games (not 2 stops)
let totalGamesInNYC = nycStops.flatMap { $0.games }.count let totalGamesInNYC = nycStops.flatMap { $0.games }.count
#expect(totalGamesInNYC >= 2, "Both games should be in the route") #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,12 +384,15 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("in-range")) #expect(allGameIds.contains("in-range"))
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included") #expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
} }
}
@Test("Invariant: A-B-A creates 3 stops not 2") @Test("Invariant: A-B-A creates 3 stops not 2")
func invariant_visitSameCityTwice_createsThreeStops() { func invariant_visitSameCityTwice_createsThreeStops() {
@@ -419,17 +427,19 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
// Look for an option that includes all 3 games Issue.record("Expected .success, got \(result)")
let optionWithAllGames = options.first { option in return
let allGames = option.stops.flatMap { $0.games }
return allGames.contains("nyc1") && allGames.contains("boston1") && allGames.contains("nyc2")
} }
if let option = optionWithAllGames { // Look for an option that includes all 3 games
// NYC appears first and last, so should have at least 3 stops let optionWithAllGames = options.first { option in
#expect(option.stops.count >= 3, "A-B-A pattern should create 3 stops") 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,12 +472,55 @@ struct ScenarioAPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Success must have at least one option") #expect(!options.isEmpty, "Success must have at least one option")
for option in options { for option in options {
#expect(!option.stops.isEmpty, "Each option must have at least one stop") #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")
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods

View File

@@ -129,14 +129,17 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let gameIds = option.stops.flatMap { $0.games } let gameIds = option.stops.flatMap { $0.games }
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game") #expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game") #expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
} }
} }
}
// MARK: - Regression Tests: Bonus Games in Date Range // MARK: - Regression Tests: Bonus Games in Date Range
@@ -291,10 +294,12 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should succeed even without explicit dates because of sliding window // Should succeed even without explicit dates because of sliding window
if case .success(let options) = result { guard case .success(let options) = result else {
#expect(!options.isEmpty) 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") @Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
@@ -418,14 +423,17 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let gameIds = Set(option.stops.flatMap { $0.games }) let gameIds = Set(option.stops.flatMap { $0.games })
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor") #expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor") #expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
} }
} }
}
// MARK: - Property Tests // MARK: - Property Tests
@@ -458,13 +466,48 @@ struct ScenarioBPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Success must have options") #expect(!options.isEmpty, "Success must have options")
for option in options { for option in options {
let allGames = option.stops.flatMap { $0.games } let allGames = option.stops.flatMap { $0.games }
#expect(allGames.contains("anchor1"), "Every option must include anchor") #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)")
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods

View File

@@ -286,12 +286,14 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// LA game should NOT be in any route (wrong direction) // LA game should NOT be in any route (wrong direction)
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)") #expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
} }
}
// MARK: - Specification Tests: Start/End Stops // MARK: - Specification Tests: Start/End Stops
@@ -336,7 +338,10 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
// First stop should be start city // First stop should be start city
#expect(option.stops.first?.city == "Chicago", "First stop should be start city") #expect(option.stops.first?.city == "Chicago", "First stop should be start city")
@@ -344,7 +349,6 @@ struct ScenarioCPlannerTests {
#expect(option.stops.last?.city == "New York", "Last stop should be end city") #expect(option.stops.last?.city == "New York", "Last stop should be end city")
} }
} }
}
// MARK: - Invariant Tests // MARK: - Invariant Tests
@@ -383,18 +387,18 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let firstStop = option.stops.first // When start city (Chicago) has a game, the endpoint is merged into the game stop.
// The start stop (added as endpoint) should have no games // Verify the first stop IS Chicago (either as game stop or endpoint).
// Note: The first stop might be a game stop if start city has games #expect(option.stops.first?.city == "Chicago",
if firstStop?.city == "Chicago" && option.stops.count > 1 { "First stop should be the start city (Chicago)")
// If there's a separate start stop with no games, verify it // Verify the last stop is the end city
let stopsWithNoGames = option.stops.filter { $0.games.isEmpty } #expect(option.stops.last?.city == "New York",
// At minimum, there should be endpoint stops "Last stop should be the end city (New York)")
#expect(stopsWithNoGames.count >= 0) // Just ensure no crash
}
}
} }
} }
@@ -433,24 +437,61 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
#expect(option.stops.last?.city == "New York", "End city must be last stop") #expect(option.stops.last?.city == "New York", "End city must be last stop")
} }
} }
}
// MARK: - Property Tests // MARK: - Property Tests
@Test("Property: forward progress tolerance is 15%") @Test("Property: forward progress tolerance filters distant backward stadiums")
func property_forwardProgressTolerance() { func property_forwardProgressTolerance() {
// This tests the documented invariant that tolerance is 15% // Chicago NYC route. LA is far backward (west), should be excluded.
// We verify by testing that a stadium 16% backward gets filtered // Cleveland is forward (east of Chicago, toward NYC), should be included.
// vs one that is 14% backward gets 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 let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1))
// We trust the implementation matches the documented behavior let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3))
#expect(true, "Forward progress tolerance documented as 15%") 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 // MARK: - Regression Tests: Endpoint Merging
@@ -499,7 +540,10 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary") #expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options { for option in options {
// When the route includes a Houston game stop, there should NOT also be // When the route includes a Houston game stop, there should NOT also be
@@ -514,7 +558,6 @@ struct ScenarioCPlannerTests {
} }
} }
} }
}
@Test("plan: both endpoints match game cities — no redundant empty endpoints") @Test("plan: both endpoints match game cities — no redundant empty endpoints")
func plan_bothEndpointsMatchGameCities_noEmptyStops() { func plan_bothEndpointsMatchGameCities_noEmptyStops() {
@@ -557,7 +600,10 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary") #expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options { for option in options {
// When a route includes a game in an endpoint city, // When a route includes a game in an endpoint city,
@@ -575,7 +621,6 @@ struct ScenarioCPlannerTests {
} }
} }
} }
}
@Test("plan: start city differs from all game cities — adds empty endpoint stop") @Test("plan: start city differs from all game cities — adds empty endpoint stop")
func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() { func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() {
@@ -622,7 +667,10 @@ struct ScenarioCPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty) #expect(!options.isEmpty)
// For routes that include the Chicago game, the start endpoint // For routes that include the Chicago game, the start endpoint
// should be merged (no separate empty Chicago stop). // should be merged (no separate empty Chicago stop).
@@ -638,7 +686,6 @@ struct ScenarioCPlannerTests {
"Should not have both game and empty stops for Chicago") "Should not have both game and empty stops for Chicago")
} }
} }
}
// MARK: - Regression Tests: Endpoint Game Validation // MARK: - Regression Tests: Endpoint Game Validation
@@ -772,6 +819,115 @@ struct ScenarioCPlannerTests {
#expect(!options.isEmpty, "Should produce at least one itinerary") #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 // MARK: - Helper Methods
private func makeStadium( private func makeStadium(

View File

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

View File

@@ -548,41 +548,47 @@ struct ScenarioEPlannerTests {
@Test("plan: routes sorted by duration ascending") @Test("plan: routes sorted by duration ascending")
func plan_routesSortedByDurationAscending() { 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 nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
// Create multiple windows with different durations // 2 teams windowDuration = 4 days. Games must span at least 3 calendar days.
// Window 1: Games on day 1 and 2 (shorter trip) // Window 1: Games on day 1 and day 4 (tighter)
// Window 2: Games on day 10 and 14 (longer trip within window) // 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( let yankeesGame1 = makeGame(
id: "yankees-1", id: "yankees-1",
homeTeamId: "yankees", homeTeamId: "yankees",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "nyc", stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1) dateTime: day1Evening
) )
let redsoxGame1 = makeGame( let redsoxGame1 = makeGame(
id: "redsox-1", id: "redsox-1",
homeTeamId: "redsox", homeTeamId: "redsox",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "boston", stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 2) dateTime: day4Evening
) )
let yankeesGame2 = makeGame( let yankeesGame2 = makeGame(
id: "yankees-2", id: "yankees-2",
homeTeamId: "yankees", homeTeamId: "yankees",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "nyc", stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 10) dateTime: day10Evening
) )
let redsoxGame2 = makeGame( let redsoxGame2 = makeGame(
id: "redsox-2", id: "redsox-2",
homeTeamId: "redsox", homeTeamId: "redsox",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "boston", stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 12) dateTime: day13Evening
) )
let prefs = TripPreferences( let prefs = TripPreferences(
@@ -617,30 +623,43 @@ struct ScenarioEPlannerTests {
for (index, option) in options.enumerated() { for (index, option) in options.enumerated() {
#expect(option.rank == index + 1, "Routes should be ranked 1, 2, 3...") #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") @Test("plan: respects max driving time constraint")
func plan_respectsMaxDrivingTimeConstraint() { 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 // NYC and LA are ~40 hours apart by car
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) 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( let yankeesGame = makeGame(
id: "yankees-home", id: "yankees-home",
homeTeamId: "yankees", homeTeamId: "yankees",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "nyc", stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1) dateTime: day1Evening
) )
let dodgersGame = makeGame( let dodgersGame = makeGame(
id: "dodgers-home", id: "dodgers-home",
homeTeamId: "dodgers", homeTeamId: "dodgers",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "la", stadiumId: "la",
dateTime: baseDate.addingTimeInterval(86400 * 2) // Next day - impossible dateTime: day5Evening
) )
let prefs = TripPreferences( let prefs = TripPreferences(
@@ -650,7 +669,7 @@ struct ScenarioEPlannerTests {
endDate: baseDate.addingTimeInterval(86400 * 30), endDate: baseDate.addingTimeInterval(86400 * 30),
leisureLevel: .moderate, leisureLevel: .moderate,
lodgingType: .hotel, lodgingType: .hotel,
numberOfDrivers: 1, // Single driver, 8 hours max numberOfDrivers: 1, // Single driver, 8 hours max impossible NYCLA
selectedTeamIds: ["yankees", "dodgers"] selectedTeamIds: ["yankees", "dodgers"]
) )
@@ -989,31 +1008,39 @@ struct ScenarioEPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
#expect(options.count <= 10, "Should return at most 10 results") 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") @Test("Invariant: all routes contain home games from all selected teams")
func invariant_allRoutesContainAllSelectedTeams() { 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 nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) 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( let yankeesGame = makeGame(
id: "yankees-home", id: "yankees-home",
homeTeamId: "yankees", homeTeamId: "yankees",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "nyc", stadiumId: "nyc",
dateTime: baseDate.addingTimeInterval(86400 * 1) dateTime: day1Evening
) )
let redsoxGame = makeGame( let redsoxGame = makeGame(
id: "redsox-home", id: "redsox-home",
homeTeamId: "redsox", homeTeamId: "redsox",
awayTeamId: "opponent", awayTeamId: "opponent",
stadiumId: "boston", stadiumId: "boston",
dateTime: baseDate.addingTimeInterval(86400 * 2) dateTime: day4Evening
) )
let prefs = TripPreferences( let prefs = TripPreferences(
@@ -1039,7 +1066,11 @@ struct ScenarioEPlannerTests {
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options { for option in options {
let allGameIds = Set(option.stops.flatMap { $0.games }) let allGameIds = Set(option.stops.flatMap { $0.games })
@@ -1056,7 +1087,6 @@ struct ScenarioEPlannerTests {
#expect(hasRedsoxGame, "Every route must include a Red Sox home game") #expect(hasRedsoxGame, "Every route must include a Red Sox home game")
} }
} }
}
// MARK: - Region Filter Tests // MARK: - Region Filter Tests
@@ -1096,50 +1126,58 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner() let planner = ScenarioEPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Should succeed both teams have east coast games // Should succeed both teams have east coast games.
if case .success(let options) = result { // Failure is also acceptable if routing constraints prevent a valid route.
switch result {
case .success(let options):
for option in options { for option in options {
let cities = option.stops.map { $0.city } let cities = option.stops.map { $0.city }
#expect(!cities.contains("Los Angeles"), "East-only filter should exclude LA") #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") @Test("teamFirst: all regions includes everything")
func teamFirst_allRegions_includesEverything() { func teamFirst_allRegions_includesEverything() {
let teamNYC = TestFixtures.team(id: "team_nyc", city: "New York") 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 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 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 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( let prefs = TripPreferences(
planningMode: .teamFirst, planningMode: .teamFirst,
sports: [.mlb], sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [.east, .central, .west], // All regions selectedRegions: [.east, .central, .west], // All regions
selectedTeamIds: ["team_nyc", "team_la"] selectedTeamIds: ["team_nyc", "team_bos"]
) )
let request = PlanningRequest( let request = PlanningRequest(
preferences: prefs, preferences: prefs,
availableGames: [gameNYC, gameLA], availableGames: [gameNYC, gameBOS],
teams: ["team_nyc": teamNYC, "team_la": teamLA], teams: ["team_nyc": teamNYC, "team_bos": teamBOS],
stadiums: ["stadium_nyc": stadiumNYC, "stadium_la": stadiumLA] stadiums: ["stadium_nyc": stadiumNYC, "stadium_bos": stadiumBOS]
) )
let planner = ScenarioEPlanner() let planner = ScenarioEPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
// With all regions, both games should be available // With all regions and nearby east-coast cities, planning should succeed
// (may still fail due to driving constraints, but games won't be region-filtered) guard case .success(let options) = result else {
#expect(result.isSuccess || result.failure?.reason == .constraintsUnsatisfiable || result.failure?.reason == .noValidRoutes) 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") @Test("teamFirst: empty regions includes everything")
@@ -1151,14 +1189,16 @@ struct ScenarioEPlannerTests {
let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston") let stadiumBOS = TestFixtures.stadium(id: "stadium_bos", city: "Boston")
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0) 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 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( let prefs = TripPreferences(
planningMode: .teamFirst, planningMode: .teamFirst,
sports: [.mlb], sports: [.mlb],
numberOfDrivers: 2,
selectedRegions: [], // Empty = no filtering selectedRegions: [], // Empty = no filtering
selectedTeamIds: ["team_nyc", "team_bos"] selectedTeamIds: ["team_nyc", "team_bos"]
) )
@@ -1173,8 +1213,12 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner() let planner = ScenarioEPlanner()
let result = planner.plan(request: request) let result = planner.plan(request: request)
// Empty regions = no filtering, so both games should be available // Empty regions = no filtering, so both games should be available and route feasible
#expect(result.isSuccess || result.failure?.reason != .noGamesInRange) 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 // MARK: - Past Date Filtering Tests
@@ -1256,7 +1300,11 @@ struct ScenarioEPlannerTests {
let planner = ScenarioEPlanner(currentDate: currentDate) let planner = ScenarioEPlanner(currentDate: currentDate)
let result = planner.plan(request: request) let result = planner.plan(request: request)
if case .success(let options) = result { guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// All returned stops should be on or after currentDate // All returned stops should be on or after currentDate
for option in options { for option in options {
for stop in option.stops { for stop in option.stops {
@@ -1265,8 +1313,6 @@ struct ScenarioEPlannerTests {
} }
} }
} }
// Failure is acceptable if routing constraints prevent a valid route
}
@Test("teamFirst: evaluates all sampled windows across full season") @Test("teamFirst: evaluates all sampled windows across full season")
func teamFirst_evaluatesAllSampledWindows_fullSeasonCoverage() { func teamFirst_evaluatesAllSampledWindows_fullSeasonCoverage() {
@@ -1328,6 +1374,165 @@ struct ScenarioEPlannerTests {
#expect(months.count >= 2, "Results should span at least 2 months, got months: \(months.sorted())") #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 // MARK: - Helper Methods
private func makeStadium( private func makeStadium(

View File

@@ -278,7 +278,7 @@ struct TravelIntegrity_EngineGateTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for (i, option) in options.enumerated() { for (i, option) in options.enumerated() {
#expect(option.isValid, #expect(option.isValid,
"Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID") "Option \(i) has \(option.stops.count) stops but \(option.travelSegments.count) segments — INVALID")
@@ -289,7 +289,6 @@ struct TravelIntegrity_EngineGateTests {
} }
} }
} }
}
@Test("Engine rejects all-invalid options with segmentMismatch failure") @Test("Engine rejects all-invalid options with segmentMismatch failure")
func engine_rejectsAllInvalid() { func engine_rejectsAllInvalid() {
@@ -624,7 +623,7 @@ struct TravelIntegrity_EdgeCaseTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
for i in 0..<option.travelSegments.count { for i in 0..<option.travelSegments.count {
let segment = option.travelSegments[i] let segment = option.travelSegments[i]
@@ -640,7 +639,6 @@ struct TravelIntegrity_EdgeCaseTests {
} }
} }
} }
}
// MARK: - Stress Tests // MARK: - Stress Tests

View File

@@ -20,48 +20,6 @@ struct TripPlanningEngineTests {
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233) private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673) 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 // MARK: - Specification Tests: Driving Constraints
@Test("DrivingConstraints: calculates maxDailyDrivingHours correctly") @Test("DrivingConstraints: calculates maxDailyDrivingHours correctly")
@@ -177,7 +135,7 @@ struct TripPlanningEngineTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
for option in options { for option in options {
#expect(option.isValid, "Every returned option must be valid (segments = stops - 1)") #expect(option.isValid, "Every returned option must be valid (segments = stops - 1)")
if option.stops.count > 1 { if option.stops.count > 1 {
@@ -185,7 +143,6 @@ struct TripPlanningEngineTests {
} }
} }
} }
}
@Test("planTrip: N stops always have exactly N-1 travel segments") @Test("planTrip: N stops always have exactly N-1 travel segments")
func planTrip_nStops_haveExactlyNMinus1Segments() { func planTrip_nStops_haveExactlyNMinus1Segments() {
@@ -219,7 +176,7 @@ struct TripPlanningEngineTests {
let engine = TripPlanningEngine() let engine = TripPlanningEngine()
let result = engine.planItineraries(request: request) let result = engine.planItineraries(request: request)
if case .success(let options) = result { guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
#expect(!options.isEmpty, "Should produce at least one option") #expect(!options.isEmpty, "Should produce at least one option")
for option in options { for option in options {
if option.stops.count > 1 { if option.stops.count > 1 {
@@ -231,7 +188,6 @@ struct TripPlanningEngineTests {
} }
} }
} }
}
@Test("planTrip: invalid options are filtered out") @Test("planTrip: invalid options are filtered out")
func planTrip_invalidOptions_areFilteredOut() { func planTrip_invalidOptions_areFilteredOut() {