Files
Sportstime/SportsTimeTests/Planning/ScenarioCPlannerTests.swift
Trey T 9b622f8bbb 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>
2026-04-04 13:38:41 -05:00

967 lines
39 KiB
Swift

//
// ScenarioCPlannerTests.swift
// SportsTimeTests
//
// TDD specification tests for ScenarioCPlanner.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ScenarioCPlanner")
struct ScenarioCPlannerTests {
// MARK: - Test Data
private let planner = ScenarioCPlanner()
// Coordinates: Chicago -> Cleveland -> New York (west to east)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4995, longitude: -81.6954)
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
// MARK: - Specification Tests: Missing Start Location
@Test("plan: no start location returns missingLocations failure")
func plan_noStartLocation_returnsMissingLocations() {
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: nil, // Missing
endLocation: endLocation,
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when start location missing")
return
}
#expect(failure.reason == .missingLocations)
}
// MARK: - Specification Tests: Missing End Location
@Test("plan: no end location returns missingLocations failure")
func plan_noEndLocation_returnsMissingLocations() {
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: nil, // Missing
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when end location missing")
return
}
#expect(failure.reason == .missingLocations)
}
// MARK: - Specification Tests: Missing Coordinates
@Test("plan: locations without coordinates returns missingLocations")
func plan_locationsWithoutCoordinates_returnsMissingLocations() {
let startLocation = LocationInput(name: "Chicago", coordinate: nil) // No coord
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: [:]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when coordinates missing")
return
}
#expect(failure.reason == .missingLocations)
}
// MARK: - Specification Tests: No Stadiums in Cities
@Test("plan: no stadiums in start city returns noGamesInRange")
func plan_noStadiumsInStartCity_returnsFailure() {
let startLocation = LocationInput(name: "Nowhere", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: ["nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no stadiums in start city")
return
}
#expect(failure.reason == .noGamesInRange)
}
@Test("plan: no stadiums in end city returns noGamesInRange")
func plan_noStadiumsInEndCity_returnsFailure() {
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "Nowhere", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7),
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [],
teams: [:],
stadiums: ["chicago": chicagoStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no stadiums in end city")
return
}
#expect(failure.reason == .noGamesInRange)
}
@Test("plan: city names with state suffixes match stadium city names")
func plan_cityNamesWithStateSuffixes_matchStadiumCities() {
let baseDate = TestClock.now
let endDate = baseDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: baseDate.addingTimeInterval(86400 * 1))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: baseDate.addingTimeInterval(86400 * 4))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: baseDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, nycGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .success = result else {
Issue.record("Expected success with city/state location labels matching plain stadium cities")
return
}
}
// MARK: - Specification Tests: Directional Filtering
@Test("plan: directional filtering includes stadiums toward destination")
func plan_directionalFiltering_includesCorrectStadiums() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 14)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
// Stadiums
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) // Between Chicago and NYC
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) // Wrong direction
// Games
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 3))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 6))
let laGame = makeGame(id: "la-game", stadiumId: "la", dateTime: startDate.addingTimeInterval(86400 * 4))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, clevelandGame, nycGame, laGame],
teams: [:],
stadiums: [
"chicago": chicagoStadium,
"cleveland": clevelandStadium,
"nyc": nycStadium,
"la": laStadium
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// LA game should NOT be in any route (wrong direction)
#expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)")
}
// MARK: - Specification Tests: Start/End Stops
@Test("plan: adds start and end as non-game stops")
func plan_addsStartEndAsNonGameStops() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 3))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, clevelandGame, nycGame],
teams: [:],
stadiums: [
"chicago": chicagoStadium,
"cleveland": clevelandStadium,
"nyc": nycStadium
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
// First stop should be start city
#expect(option.stops.first?.city == "Chicago", "First stop should be start city")
// Last stop should be end city
#expect(option.stops.last?.city == "New York", "Last stop should be end city")
}
}
// MARK: - Invariant Tests
@Test("Invariant: start stop has no games")
func invariant_startStopHasNoGames() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, nycGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
// When start city (Chicago) has a game, the endpoint is merged into the game stop.
// Verify the first stop IS Chicago (either as game stop or endpoint).
#expect(option.stops.first?.city == "Chicago",
"First stop should be the start city (Chicago)")
// Verify the last stop is the end city
#expect(option.stops.last?.city == "New York",
"Last stop should be the end city (New York)")
}
}
@Test("Invariant: end stop appears last")
func invariant_endStopAppearsLast() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, nycGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "nyc": nycStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
#expect(option.stops.last?.city == "New York", "End city must be last stop")
}
}
// MARK: - Property Tests
@Test("Property: forward progress tolerance filters distant backward stadiums")
func property_forwardProgressTolerance() {
// Chicago NYC route. LA is far backward (west), should be excluded.
// Cleveland is forward (east of Chicago, toward NYC), should be included.
let chicagoStad = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4958, longitude: -81.6853)
let clevelandStad = makeStadium(id: "cle", city: "Cleveland", coordinate: clevelandCoord)
let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400)
let laStad = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1))
let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3))
let laGame = makeGame(id: "g_la", stadiumId: "la", dateTime: TestClock.addingDays(4))
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(6))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord),
endLocation: LocationInput(name: "New York", coordinate: nycCoord),
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chiGame, cleGame, laGame, nycGame],
teams: [:],
stadiums: ["chi": chicagoStad, "nyc": nycStad, "cle": clevelandStad, "la": laStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let cities = option.stops.map(\.city)
#expect(!cities.contains("Los Angeles"),
"LA is far backward from Chicago→NYC route and should be excluded")
}
}
// MARK: - Regression Tests: Endpoint Merging
private let houstonCoord = CLLocationCoordinate2D(latitude: 29.7604, longitude: -95.3698)
private let denverCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903)
@Test("plan: start city matches first game city — no redundant empty endpoint")
func plan_startCityMatchesFirstGameCity_noZeroMileTravel() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Houston, TX", coordinate: houstonCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let houstonStadium = makeStadium(id: "houston", city: "Houston", coordinate: houstonCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let houstonGame = makeGame(id: "hou-game", stadiumId: "houston", dateTime: startDate.addingTimeInterval(86400))
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [houstonGame, clevelandGame, nycGame],
teams: [:],
stadiums: [
"houston": houstonStadium,
"cleveland": clevelandStadium,
"nyc": nycStadium
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When the route includes a Houston game stop, there should NOT also be
// a separate empty Houston endpoint stop (the fix merges them)
let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" }
let emptyHoustonStops = houstonStops.filter { !$0.hasGames }
let gameHoustonStops = houstonStops.filter { $0.hasGames }
if !gameHoustonStops.isEmpty {
#expect(emptyHoustonStops.isEmpty,
"Should not have both a game stop and empty endpoint in Houston")
}
}
}
@Test("plan: both endpoints match game cities — no redundant empty endpoints")
func plan_bothEndpointsMatchGameCities_noEmptyStops() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, clevelandGame, nycGame],
teams: [:],
stadiums: [
"chicago": chicagoStadium,
"cleveland": clevelandStadium,
"nyc": nycStadium
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary")
for option in options {
// When a route includes a game in an endpoint city,
// there should NOT also be a separate empty endpoint stop for that city
let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" }
if chicagoStops.contains(where: { $0.hasGames }) {
#expect(!chicagoStops.contains(where: { !$0.hasGames }),
"No redundant empty Chicago endpoint when game stop exists")
}
let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" }
if nycStops.contains(where: { $0.hasGames }) {
#expect(!nycStops.contains(where: { !$0.hasGames }),
"No redundant empty NYC endpoint when game stop exists")
}
}
}
@Test("plan: start city differs from all game cities — adds empty endpoint stop")
func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
// Start from a city that has a stadium but the route games are elsewhere
// Use Pittsburgh as an intermediate that differs from Chicago start
let pittsburghCoord = CLLocationCoordinate2D(latitude: 40.4406, longitude: -79.9959)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "New York", coordinate: nycCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let pittsburghStadium = makeStadium(id: "pittsburgh", city: "Pittsburgh", coordinate: pittsburghCoord)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
// Chicago game at start, Cleveland game at a non-endpoint city
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let pittsburghGame = makeGame(id: "pit-game", stadiumId: "pittsburgh", dateTime: startDate.addingTimeInterval(86400 * 4))
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, pittsburghGame, nycGame],
teams: [:],
stadiums: [
"chicago": chicagoStadium,
"pittsburgh": pittsburghStadium,
"nyc": nycStadium
]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
#expect(!options.isEmpty)
// For routes that include the Chicago game, the start endpoint
// should be merged (no separate empty Chicago stop).
// For routes that don't include the Chicago game, an empty
// Chicago endpoint is correctly added.
for option in options {
let chicagoStops = option.stops.filter { $0.city == "Chicago" }
let hasGameInChicago = chicagoStops.contains { $0.hasGames }
let hasEmptyChicago = chicagoStops.contains { !$0.hasGames }
// Should never have BOTH an empty endpoint and a game stop for same city
#expect(!(hasGameInChicago && hasEmptyChicago),
"Should not have both game and empty stops for Chicago")
}
}
// MARK: - Regression Tests: Endpoint Game Validation
@Test("plan: explicit date range with no games at end city returns failure")
func plan_explicitDateRange_noGamesAtEndCity_returnsFailure() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 7)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
// Game at start city, but NO game at end city within date range
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no games at end city within date range")
return
}
#expect(failure.reason == .noGamesInRange)
#expect(failure.violations.first?.description.contains("Cleveland") == true,
"Violation should mention end city")
}
@Test("plan: explicit date range with no games at start city returns failure")
func plan_explicitDateRange_noGamesAtStartCity_returnsFailure() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 7)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
// Game at end city, but NO game at start city within date range
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [clevelandGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when no games at start city within date range")
return
}
#expect(failure.reason == .noGamesInRange)
#expect(failure.violations.first?.description.contains("Chicago") == true,
"Violation should mention start city")
}
@Test("plan: explicit date range with games at both cities succeeds")
func plan_explicitDateRange_gamesAtBothCities_succeeds() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord)
let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord)
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord)
let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord)
let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400))
let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5))
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLocation,
endLocation: endLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [chicagoGame, clevelandGame],
teams: [:],
stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success when games exist at both endpoint cities")
return
}
#expect(!options.isEmpty, "Should produce at least one itinerary")
}
// MARK: - Output Sanity
@Test("plan: all stops progress toward end location")
func plan_allStopsProgressTowardEnd() {
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
let phillyC = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665)
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
let phillyStad = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyC)
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
let games = [
makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)),
makeGame(id: "g_philly", stadiumId: "philly", dateTime: TestClock.addingDays(3)),
makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(5)),
makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)),
]
let startLoc = LocationInput(name: "New York", coordinate: nycC)
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLoc,
endLocation: endLoc,
sports: [.mlb],
startDate: TestClock.addingDays(0),
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: games,
teams: [:],
stadiums: ["nyc": nycStad, "philly": phillyStad, "dc": dcStad, "atl": atlantaStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let gameStops = option.stops.filter { !$0.games.isEmpty }
for i in 0..<(gameStops.count - 1) {
if let coord1 = gameStops[i].coordinate, let coord2 = gameStops[i + 1].coordinate {
let progressing = coord2.latitude <= coord1.latitude + 2.0
#expect(progressing,
"Stops should progress toward Atlanta (south): \(gameStops[i].city)\(gameStops[i+1].city)")
}
}
}
}
@Test("plan: games outside directional cone excluded")
func plan_gamesOutsideDirectionalCone_excluded() {
let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006)
let bostonC = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074)
let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC)
let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC)
let bostonStad = makeStadium(id: "boston", city: "Boston", coordinate: bostonC)
let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC)
let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1))
let atlGame = makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7))
let bostonGame = makeGame(id: "g_boston", stadiumId: "boston", dateTime: TestClock.addingDays(3))
let dcGame = makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(4))
let startLoc = LocationInput(name: "New York", coordinate: nycC)
let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC)
let prefs = TripPreferences(
planningMode: .locations,
startLocation: startLoc,
endLocation: endLoc,
sports: [.mlb],
startDate: TestClock.addingDays(0),
endDate: TestClock.addingDays(10),
numberOfDrivers: 2
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [nycGame, atlGame, bostonGame, dcGame],
teams: [:],
stadiums: ["nyc": nycStad, "atl": atlantaStad, "boston": bostonStad, "dc": dcStad]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
for option in options {
let cities = option.stops.map(\.city)
#expect(!cities.contains("Boston"),
"Boston (north of NYC) should be excluded when traveling NYC→Atlanta")
}
}
// MARK: - Helper Methods
private func makeStadium(
id: String,
city: String,
coordinate: CLLocationCoordinate2D
) -> Stadium {
Stadium(
id: id,
name: "\(city) Stadium",
city: city,
state: "XX",
latitude: coordinate.latitude,
longitude: coordinate.longitude,
capacity: 40000,
sport: .mlb
)
}
private func makeGame(
id: String,
stadiumId: String,
dateTime: Date
) -> Game {
Game(
id: id,
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: stadiumId,
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
}
}