Files
Sportstime/SportsTimeTests/Planning/ScenarioDPlannerTests.swift
Trey T 9b622f8bbb Harden planning test suite with realistic fixtures and output sanity checks
Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests,
and updates all scenario planner tests with improved coverage and assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:38:41 -05:00

571 lines
18 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)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
// MARK: - Specification Tests: Missing Team
@Test("plan: no followTeamId returns missingTeamSelection failure")
func plan_noFollowTeamId_returnsMissingTeamSelection() {
let startDate = TestClock.now
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 = TestClock.now
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 = 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)
// 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)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
// At least one option should include BOTH home and away games
let hasOptionWithBoth = options.contains { option in
let gameIds = Set(option.stops.flatMap { $0.games })
return gameIds.contains("home-game") && gameIds.contains("away-game")
}
#expect(hasOptionWithBoth, "At least one option should include both home and away games")
}
// MARK: - Specification Tests: Region Filtering
@Test("plan: with selectedRegions filters team games to those regions")
func plan_withSelectedRegions_filtersTeamGames() {
let startDate = TestClock.now
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)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(allGameIds.contains("east-game"), "East game should be included when East region is selected")
#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 = TestClock.now
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)
}
@Test("plan: useHomeLocation with startLocation adds home start and end stops")
func plan_useHomeLocationWithStartLocation_addsHomeEndpoints() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 10)
let homeCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // NYC
let homeLocation = LocationInput(name: "New York", coordinate: homeCoord)
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
let game = Game(
id: "team-game",
homeTeamId: "red-sox",
awayTeamId: "yankees",
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 3),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let prefs = TripPreferences(
planningMode: .followTeam,
startLocation: homeLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
lodgingType: .hotel,
numberOfDrivers: 2,
followTeamId: "yankees",
useHomeLocation: true
)
let request = PlanningRequest(
preferences: prefs,
availableGames: [game],
teams: [:],
stadiums: ["boston": bostonStadium]
)
let result = planner.plan(request: request)
guard case .success(let options) = result else {
Issue.record("Expected success with home endpoint enabled")
return
}
#expect(!options.isEmpty)
for option in options {
#expect(option.stops.first?.city == "New York")
#expect(option.stops.last?.city == "New York")
#expect(option.stops.first?.games.isEmpty == true)
#expect(option.stops.last?.games.isEmpty == true)
}
}
// MARK: - Invariant Tests
@Test("Invariant: all returned games have team as home or away")
func invariant_allGamesHaveTeam() {
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 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)
guard case .success(let options) = result else {
Issue.record("Expected .success, got \(result)")
return
}
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
#expect(!allGameIds.contains("other"), "Games not involving the followed team should be excluded")
// Full invariant: ALL returned games must involve the followed team
let allGames = [homeGame, awayGame, otherGame]
for gameId in allGameIds {
let game = allGames.first { $0.id == gameId }
#expect(game != nil, "Game ID \(gameId) should be in the available games list")
if let game = game {
#expect(game.homeTeamId == teamId || game.awayTeamId == teamId,
"Game \(gameId) should involve followed team \(teamId)")
}
}
}
@Test("Invariant: duplicate routes are removed")
func invariant_duplicateRoutesRemoved() {
let startDate = TestClock.now
let endDate = startDate.addingTimeInterval(86400 * 14)
// 3 games for the followed team at nearby cities the DAG router may
// produce multiple routes (e.g. [NYC, BOS], [NYC, PHI], [NYC, BOS, PHI])
// which makes the uniqueness check meaningful.
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 game1 = Game(
id: "game-nyc",
homeTeamId: "yankees",
awayTeamId: "opponent",
stadiumId: "nyc",
dateTime: startDate.addingTimeInterval(86400 * 2),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let game2 = Game(
id: "game-bos",
homeTeamId: "red-sox",
awayTeamId: "yankees",
stadiumId: "boston",
dateTime: startDate.addingTimeInterval(86400 * 5),
sport: .mlb,
season: "2026",
isPlayoff: false
)
let game3 = Game(
id: "game-phi",
homeTeamId: "phillies",
awayTeamId: "yankees",
stadiumId: "philly",
dateTime: startDate.addingTimeInterval(86400 * 8),
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: [game1, game2, game3],
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, got \(result)")
return
}
// Verify no two options have identical game-ID sets
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 = TestClock.now
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, got \(result)")
return
}
#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
)
}
}