Eliminate redundant 0-mile travel segments when start/end city matches the first/last game stop city, and fail early when no games exist at endpoint cities within the selected date range. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
811 lines
32 KiB
Swift
811 lines
32 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)
|
|
|
|
if case .success(let options) = result {
|
|
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)
|
|
|
|
if case .success(let options) = result {
|
|
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)
|
|
|
|
if case .success(let options) = result {
|
|
for option in options {
|
|
let firstStop = option.stops.first
|
|
// The start stop (added as endpoint) should have no games
|
|
// Note: The first stop might be a game stop if start city has games
|
|
if firstStop?.city == "Chicago" && option.stops.count > 1 {
|
|
// If there's a separate start stop with no games, verify it
|
|
let stopsWithNoGames = option.stops.filter { $0.games.isEmpty }
|
|
// At minimum, there should be endpoint stops
|
|
#expect(stopsWithNoGames.count >= 0) // Just ensure no crash
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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)
|
|
|
|
if case .success(let options) = result {
|
|
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 is 15%")
|
|
func property_forwardProgressTolerance() {
|
|
// This tests the documented invariant that tolerance is 15%
|
|
// We verify by testing that a stadium 16% backward gets filtered
|
|
// vs one that is 14% backward gets included
|
|
|
|
// This is more of a documentation test - the actual tolerance is private
|
|
// We trust the implementation matches the documented behavior
|
|
#expect(true, "Forward progress tolerance documented as 15%")
|
|
}
|
|
|
|
// 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)
|
|
|
|
if case .success(let options) = result {
|
|
#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)
|
|
|
|
if case .success(let options) = result {
|
|
#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)
|
|
|
|
if case .success(let options) = result {
|
|
#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: - 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
|
|
)
|
|
}
|
|
}
|