Files
Sportstime/SportsTimeTests/Planning/ScenarioBPlannerTests.swift
Trey t 8162b4a029 refactor(tests): TDD rewrite of all unit tests with spec documentation
Complete rewrite of unit test suite using TDD methodology:

Planning Engine Tests:
- GameDAGRouterTests: Beam search, anchor games, transitions
- ItineraryBuilderTests: Stop connection, validators, EV enrichment
- RouteFiltersTests: Region, time window, scoring filters
- ScenarioA/B/C/D PlannerTests: All planning scenarios
- TravelEstimatorTests: Distance, duration, travel days
- TripPlanningEngineTests: Orchestration, caching, preferences

Domain Model Tests:
- AchievementDefinitionsTests, AnySportTests, DivisionTests
- GameTests, ProgressTests, RegionTests, StadiumTests
- TeamTests, TravelSegmentTests, TripTests, TripPollTests
- TripPreferencesTests, TripStopTests, SportTests

Service Tests:
- FreeScoreAPITests, RouteDescriptionGeneratorTests
- SuggestedTripsGeneratorTests

Export Tests:
- ShareableContentTests (card types, themes, dimensions)

Bug fixes discovered through TDD:
- ShareCardDimensions: mapSnapshotSize exceeded available width (960x480)
- ScenarioBPlanner: Added anchor game validation filter

All tests include:
- Specification tests (expected behavior)
- Invariant tests (properties that must always hold)
- Edge case tests (boundary conditions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:07:41 -06:00

342 lines
12 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: - 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
)
}
}