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>
967 lines
39 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|