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>
571 lines
18 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|