Files
Sportstime/SportsTimeTests/Planning/ScenarioDPlannerTests.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

462 lines
14 KiB
Swift

//
// ScenarioDPlannerTests.swift
// SportsTimeTests
//
// TDD specification tests for ScenarioDPlanner.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ScenarioDPlanner")
struct ScenarioDPlannerTests {
// MARK: - Test Data
private let planner = ScenarioDPlanner()
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
// MARK: - Specification Tests: Missing Team
@Test("plan: no followTeamId returns missingTeamSelection failure")
func plan_noFollowTeamId_returnsMissingTeamSelection() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 7)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: nil // Missing
)
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 followTeamId missing")
return
}
#expect(failure.reason == .missingTeamSelection)
}
// MARK: - Specification Tests: No Team Games
@Test("plan: no games for team returns noGamesInRange failure")
func plan_noGamesForTeam_returnsNoGamesInRange() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 7)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
// Game is for different team
let game = Game(
id: "game1",
homeTeamId: "other-team",
awayTeamId: "another-team",
stadiumId: "stadium1",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: "yankees" // Team not in any game
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
teams: [:],
stadiums: ["stadium1": stadium]
)
let result = planner.plan(request: request)
guard case .failure(let failure) = result else {
Issue.record("Expected failure when team has no games")
return
}
#expect(failure.reason == .noGamesInRange)
}
// MARK: - Specification Tests: Home and Away Games
@Test("plan: includes both home and away games for team")
func plan_includesBothHomeAndAwayGames() {
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)
// Home game: Yankees at home
let homeGame = Game(
id: "home-game",
homeTeamId: "yankees",
awayTeamId: "red-sox",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
// Away game: Yankees away
let awayGame = Game(
id: "away-game",
homeTeamId: "red-sox",
awayTeamId: "yankees",
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 5),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [homeGame, awayGame],
teams: [:],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
// Both home and away games should be includable
let hasHomeGame = allGameIds.contains("home-game")
let hasAwayGame = allGameIds.contains("away-game")
#expect(hasHomeGame || hasAwayGame, "Should include at least one team game")
}
}
// MARK: - Specification Tests: Region Filtering
@Test("plan: with selectedRegions filters team games to those regions")
func plan_withSelectedRegions_filtersTeamGames() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 14)
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) // East
let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) // Central
// East coast game
let eastGame = Game(
id: "east-game",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
// Central game
let centralGame = Game(
id: "central-game",
homeTeamId: "cubs",
awayTeamId: "yankees",
stadiumId: "chicago",
dateTime: startDate.addingTimeInterval(86400 * 5),
sport: .mlb,
season: "2026",
isPlayoff: false
)
var prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: "yankees"
)
prefs.selectedRegions = [.east] // Only East coast
let request = PlanningRequest(
preferences: prefs,
availableGames: [eastGame, centralGame],
teams: [:],
stadiums: ["nyc": nycStadium, "chicago": chicagoStadium]
)
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("central-game"), "Central game should be filtered by East region")
}
}
// MARK: - Specification Tests: Successful Planning
@Test("plan: valid request returns success")
func plan_validRequest_returnsSuccess() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 7)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
let game = Game(
id: "game1",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "stadium1",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: "yankees"
)
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 valid follow team request")
return
}
#expect(!options.isEmpty)
}
// MARK: - Invariant Tests
@Test("Invariant: all returned games have team as home or away")
func invariant_allGamesHaveTeam() {
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 teamId = "yankees"
// Games involving the team
let homeGame = Game(
id: "home",
homeTeamId: teamId,
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let awayGame = Game(
id: "away",
homeTeamId: "red-sox",
awayTeamId: teamId,
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 5),
sport: .mlb,
season: "2026",
isPlayoff: false
)
// Game NOT involving the team
let otherGame = Game(
id: "other",
homeTeamId: "mets",
awayTeamId: "phillies",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 3),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
followTeamId: teamId
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [homeGame, awayGame, otherGame],
teams: [:],
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
)
let result = planner.plan(request: request)
if case .success(let options) = result {
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
}
}
@Test("Invariant: duplicate routes are removed")
func invariant_duplicateRoutesRemoved() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 7)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
let game = Game(
id: "game1",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "stadium1",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: "yankees"
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
teams: [:],
stadiums: ["stadium1": stadium]
)
let result = planner.plan(request: request)
if case .success(let options) = result {
// Verify no duplicate game combinations
var seenGameCombinations = Set<String>()
for option in options {
let gameIds = option.stops.flatMap { $0.games }.sorted().joined(separator: "-")
#expect(!seenGameCombinations.contains(gameIds), "Duplicate route found: \(gameIds)")
seenGameCombinations.insert(gameIds)
}
}
}
// MARK: - Property Tests
@Test("Property: success always has non-empty options")
func property_successHasNonEmptyOptions() {
let startDate = Date()
let endDate = startDate.addingTimeInterval(86400 * 7)
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
let game = Game(
id: "game1",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "stadium1",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 1,
followTeamId: "yankees"
)
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 at least one option")
for option in options {
#expect(!option.stops.isEmpty, "Each option must have stops")
}
}
}
// 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
)
}
}