Two bugs fixed in "By Games" trip planning mode: 1. Calendar navigation: DateRangePicker now navigates to the selected game's month when startDate changes externally, instead of staying on the current month. 2. Date range calculation: Fixed race condition where date range was calculated before games were loaded. Now updateDateRangeForSelectedGames() is called after loadSummaryGames() completes. 3. Bonus games: planTrip() now uses the UI-selected 7-day date range instead of overriding it with just the anchor game dates. This allows ScenarioBPlanner to find additional games within the trip window. Added regression tests to verify gameFirst mode includes bonus games. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
463 lines
17 KiB
Swift
463 lines
17 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 = Date()
|
|
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 = Date()
|
|
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 = Date()
|
|
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 = Date()
|
|
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 = Date()
|
|
|
|
// 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 = Date().addingTimeInterval(86400 * 5)
|
|
let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .gameFirst,
|
|
sports: [.mlb],
|
|
mustSeeGameIds: ["game1"],
|
|
startDate: Date(),
|
|
endDate: Date().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
|
|
}
|
|
|
|
// 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 = Date()
|
|
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 = Date()
|
|
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 = Date()
|
|
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
|
|
)
|
|
}
|
|
}
|