506 lines
18 KiB
Swift
506 lines
18 KiB
Swift
//
|
|
// ScenarioBPlannerTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// TDD specification tests for ScenarioBPlanner.
|
|
//
|
|
|
|
import Testing
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
@Suite("ScenarioBPlanner")
|
|
struct ScenarioBPlannerTests {
|
|
|
|
// MARK: - Test Data
|
|
|
|
private let planner = ScenarioBPlanner()
|
|
|
|
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
|
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
|
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
|
|
|
|
// MARK: - Specification Tests: No Selected Games
|
|
|
|
@Test("plan: no selected games returns failure")
|
|
func plan_noSelectedGames_returnsFailure() {
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: [], // No selected games
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
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 no games selected")
|
|
return
|
|
}
|
|
#expect(failure.reason == .noValidRoutes)
|
|
}
|
|
|
|
// MARK: - Specification Tests: Anchor Games
|
|
|
|
@Test("plan: single selected game returns success with that game")
|
|
func plan_singleSelectedGame_returnsSuccess() {
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
let gameDate = startDate.addingTimeInterval(86400 * 2)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["game1"],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .success(let options) = result else {
|
|
Issue.record("Expected success with single selected game")
|
|
return
|
|
}
|
|
|
|
#expect(!options.isEmpty)
|
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
|
#expect(allGameIds.contains("game1"), "Selected game must be in result")
|
|
}
|
|
|
|
@Test("plan: all selected games appear in every route")
|
|
func plan_allSelectedGamesAppearInRoutes() {
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 10)
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
|
|
|
|
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400))
|
|
let bostonGame = makeGame(id: "boston-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3))
|
|
let phillyGame = makeGame(id: "philly-game", stadiumId: "philly", dateTime: startDate.addingTimeInterval(86400 * 5))
|
|
|
|
// Select NYC and Boston games as anchors
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["nyc-game", "boston-game"],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 2
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [nycGame, bostonGame, phillyGame],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
for option in options {
|
|
let gameIds = option.stops.flatMap { $0.games }
|
|
#expect(gameIds.contains("nyc-game"), "Every route must contain selected NYC game")
|
|
#expect(gameIds.contains("boston-game"), "Every route must contain selected Boston game")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Regression Tests: Bonus Games in Date Range
|
|
|
|
@Test("plan: gameFirst mode includes bonus games within date range")
|
|
func plan_gameFirstMode_includesBonusGamesInDateRange() {
|
|
// Regression test: When a single anchor game is selected, the planner should
|
|
// find additional "bonus" games within the date range that fit geographically.
|
|
// Bug: planTrip() was overriding the 7-day date range with just anchor dates,
|
|
// causing only the anchor game to appear in results.
|
|
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 7) // 7-day span
|
|
|
|
// NYC and Boston are geographically close (drivable)
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
|
|
// Anchor game on day 4
|
|
let anchorGame = makeGame(id: "anchor-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 3))
|
|
// Bonus game on day 2 (within date range, geographically sensible)
|
|
let bonusGame = makeGame(id: "bonus-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 1))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["anchor-game"], // Only anchor is selected
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 2
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [anchorGame, bonusGame], // Both games available
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .success(let options) = result else {
|
|
Issue.record("Expected success with bonus game available")
|
|
return
|
|
}
|
|
|
|
#expect(!options.isEmpty, "Should have trip options")
|
|
|
|
// At least one option should include the bonus game
|
|
let optionsWithBonus = options.filter { option in
|
|
option.stops.flatMap { $0.games }.contains("bonus-game")
|
|
}
|
|
|
|
#expect(!optionsWithBonus.isEmpty, "At least one route should include bonus game from date range")
|
|
|
|
// ALL options must still contain the anchor game
|
|
for option in options {
|
|
let gameIds = option.stops.flatMap { $0.games }
|
|
#expect(gameIds.contains("anchor-game"), "Anchor game must be in every route")
|
|
}
|
|
}
|
|
|
|
@Test("plan: gameFirst mode uses full date range not just anchor dates")
|
|
func plan_gameFirstMode_usesFullDateRange() {
|
|
// Regression test: Verify that the planner considers games across the entire
|
|
// date range, not just on the anchor game dates.
|
|
|
|
let startDate = TestClock.now
|
|
|
|
// 7-day date range
|
|
let day1 = startDate
|
|
let day3 = startDate.addingTimeInterval(86400 * 2)
|
|
let day4 = startDate.addingTimeInterval(86400 * 3) // Anchor game day
|
|
let day6 = startDate.addingTimeInterval(86400 * 5)
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord)
|
|
|
|
// Anchor game on day 4
|
|
let anchorGame = makeGame(id: "anchor", stadiumId: "nyc", dateTime: day4)
|
|
// Games on other days
|
|
let day1Game = makeGame(id: "day1-game", stadiumId: "philly", dateTime: day1)
|
|
let day3Game = makeGame(id: "day3-game", stadiumId: "boston", dateTime: day3)
|
|
let day6Game = makeGame(id: "day6-game", stadiumId: "philly", dateTime: day6)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["anchor"],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 2
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [anchorGame, day1Game, day3Game, day6Game],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .success(let options) = result else {
|
|
Issue.record("Expected success")
|
|
return
|
|
}
|
|
|
|
// Collect all game IDs across all options
|
|
let allGameIdsInOptions = Set(options.flatMap { $0.stops.flatMap { $0.games } })
|
|
|
|
// At least some non-anchor games should appear in the results
|
|
// (we don't require ALL because geographic constraints may exclude some)
|
|
let bonusGamesFound = allGameIdsInOptions.subtracting(["anchor"])
|
|
#expect(!bonusGamesFound.isEmpty, "Planner should find bonus games from full date range, not just anchor date")
|
|
}
|
|
|
|
// MARK: - Specification Tests: Sliding Window
|
|
|
|
@Test("plan: gameFirst mode uses sliding window")
|
|
func plan_gameFirstMode_usesSlidingWindow() {
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
|
|
// Game on a specific date
|
|
let gameDate = TestClock.now.addingTimeInterval(86400 * 5)
|
|
let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["game1"],
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.now.addingTimeInterval(86400 * 30),
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1,
|
|
gameFirstTripDuration: 7 // 7-day trip
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
// Should succeed even without explicit dates because of sliding window
|
|
if case .success(let options) = result {
|
|
#expect(!options.isEmpty)
|
|
}
|
|
// May also fail if no valid date ranges, which is acceptable
|
|
}
|
|
|
|
@Test("plan: explicit date range with out-of-range selected game returns dateRangeViolation")
|
|
func plan_explicitDateRange_selectedGameOutsideRange_returnsDateRangeViolation() {
|
|
let baseDate = TestClock.now
|
|
let rangeStart = baseDate
|
|
let rangeEnd = baseDate.addingTimeInterval(86400 * 3)
|
|
let outOfRangeDate = baseDate.addingTimeInterval(86400 * 10)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let selectedGame = makeGame(id: "outside-anchor", stadiumId: "stadium1", dateTime: outOfRangeDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["outside-anchor"],
|
|
startDate: rangeStart,
|
|
endDate: rangeEnd,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [selectedGame],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .failure(let failure) = result else {
|
|
Issue.record("Expected date range violation when selected game is outside explicit range")
|
|
return
|
|
}
|
|
|
|
guard case .dateRangeViolation(let violatingGames) = failure.reason else {
|
|
Issue.record("Expected .dateRangeViolation, got \(failure.reason)")
|
|
return
|
|
}
|
|
|
|
#expect(Set(violatingGames.map { $0.id }) == ["outside-anchor"])
|
|
}
|
|
|
|
// MARK: - Specification Tests: Arrival Time Validation
|
|
|
|
@Test("plan: uses arrivalBeforeGameStart validator")
|
|
func plan_usesArrivalValidator() {
|
|
// This test verifies that ScenarioB uses arrival time validation
|
|
// by creating a scenario where travel time makes arrival impossible
|
|
|
|
let now = TestClock.now
|
|
let game1Date = now.addingTimeInterval(86400) // Tomorrow
|
|
let game2Date = now.addingTimeInterval(86400 + 3600) // Tomorrow + 1 hour (impossible to drive from coast to coast)
|
|
|
|
// NYC to LA is ~40 hours of driving
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673))
|
|
|
|
let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: game1Date)
|
|
let laGame = makeGame(id: "la-game", stadiumId: "la", dateTime: game2Date)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["nyc-game", "la-game"],
|
|
startDate: now,
|
|
endDate: now.addingTimeInterval(86400 * 7),
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [nycGame, laGame],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "la": laStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
// Should fail because it's impossible to arrive in LA 1 hour after leaving NYC
|
|
guard case .failure = result else {
|
|
Issue.record("Expected failure when travel time makes arrival impossible")
|
|
return
|
|
}
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: selected games cannot be dropped")
|
|
func invariant_selectedGamesCannotBeDropped() {
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 14)
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
|
|
let nycGame = makeGame(id: "nyc-anchor", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 2))
|
|
let bostonGame = makeGame(id: "boston-anchor", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 5))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["nyc-anchor", "boston-anchor"],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 2
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [nycGame, bostonGame],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
for option in options {
|
|
let gameIds = Set(option.stops.flatMap { $0.games })
|
|
#expect(gameIds.contains("nyc-anchor"), "Anchor game cannot be dropped: nyc-anchor")
|
|
#expect(gameIds.contains("boston-anchor"), "Anchor game cannot be dropped: boston-anchor")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Property Tests
|
|
|
|
@Test("Property: success with selected games includes all anchors")
|
|
func property_successIncludesAllAnchors() {
|
|
let startDate = TestClock.now
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
let gameDate = startDate.addingTimeInterval(86400 * 2)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let game = makeGame(id: "anchor1", stadiumId: "stadium1", dateTime: gameDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["anchor1"],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
#expect(!options.isEmpty, "Success must have options")
|
|
for option in options {
|
|
let allGames = option.stops.flatMap { $0.games }
|
|
#expect(allGames.contains("anchor1"), "Every option must include anchor")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|