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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,13 +243,15 @@ 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,12 +329,14 @@ 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,12 +966,14 @@ 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"),
|
||||||
"East-only search should not include LA")
|
"East-only search should not include LA")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1098
SportsTimeTests/Planning/PlannerOutputSanityTests.swift
Normal file
1098
SportsTimeTests/Planning/PlannerOutputSanityTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -271,7 +279,6 @@ struct Bug5_ScenarioDDepartureDateTests {
|
|||||||
"Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)")
|
"Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 NYC→LA
|
||||||
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(
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -639,7 +638,6 @@ struct TravelIntegrity_EdgeCaseTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stress Tests
|
// MARK: - Stress Tests
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user