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

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