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>
460 lines
16 KiB
Swift
460 lines
16 KiB
Swift
//
|
|
// ScenarioAPlannerTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// TDD specification tests for ScenarioAPlanner.
|
|
//
|
|
|
|
import Testing
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
@Suite("ScenarioAPlanner")
|
|
struct ScenarioAPlannerTests {
|
|
|
|
// MARK: - Test Data
|
|
|
|
private let planner = ScenarioAPlanner()
|
|
private let calendar = Calendar.current
|
|
|
|
// Coordinates for testing
|
|
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 laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
|
|
|
|
// MARK: - Specification Tests: No Games
|
|
|
|
@Test("plan: no games in date range returns noGamesInRange failure")
|
|
func plan_noGamesInRange_returnsFailure() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [], // No games
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .failure(let failure) = result else {
|
|
Issue.record("Expected failure, got success")
|
|
return
|
|
}
|
|
#expect(failure.reason == .noGamesInRange)
|
|
}
|
|
|
|
@Test("plan: games outside date range returns noGamesInRange")
|
|
func plan_gamesOutsideDateRange_returnsNoGamesInRange() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
// Game is after the date range
|
|
let gameDate = endDate.addingTimeInterval(86400 * 30)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let game = makeGame(id: "game1", stadiumId: "stadium1", dateTime: gameDate)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
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 .failure(let failure) = result else {
|
|
Issue.record("Expected failure, got success")
|
|
return
|
|
}
|
|
#expect(failure.reason == .noGamesInRange)
|
|
}
|
|
|
|
// MARK: - Specification Tests: Region Filtering
|
|
|
|
@Test("plan: with selectedRegions filters to those regions")
|
|
func plan_withSelectedRegions_filtersGames() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
let gameDate = startDate.addingTimeInterval(86400 * 2)
|
|
|
|
// NYC stadium (East coast)
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
// LA stadium (West coast)
|
|
let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord)
|
|
|
|
let nycGame = makeGame(id: "game-nyc", stadiumId: "nyc", dateTime: gameDate)
|
|
let laGame = makeGame(id: "game-la", stadiumId: "la", dateTime: gameDate.addingTimeInterval(86400))
|
|
|
|
var prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
prefs.selectedRegions = [.east] // Only East coast
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [nycGame, laGame],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "la": laStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
// Should succeed with only NYC game (East coast)
|
|
guard case .success(let options) = result else {
|
|
// May fail for other reasons (no valid routes), but shouldn't include LA
|
|
return
|
|
}
|
|
|
|
// If success, verify only East coast games included
|
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
|
#expect(!allGameIds.contains("game-la"), "LA game should be filtered out by East region filter")
|
|
}
|
|
|
|
// MARK: - Specification Tests: Must-Stop Filtering
|
|
|
|
@Test("plan: with mustStopLocation filters to that city")
|
|
func plan_withMustStopLocation_filtersToCity() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 14)
|
|
let gameDate = startDate.addingTimeInterval(86400 * 2)
|
|
|
|
let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord)
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
|
|
let nycGame = makeGame(id: "game-nyc", stadiumId: "nyc", dateTime: gameDate)
|
|
let bostonGame = makeGame(id: "game-boston", stadiumId: "boston", dateTime: gameDate.addingTimeInterval(86400))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
mustStopLocations: [LocationInput(name: "New York", coordinate: nycCoord)],
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [nycGame, bostonGame],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
// If success, should only include NYC games
|
|
if case .success(let options) = result {
|
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
|
#expect(allGameIds.contains("game-nyc"), "NYC game should be included")
|
|
// Boston game may or may not be included depending on route logic
|
|
}
|
|
// Could also fail with noGamesInRange if must-stop filter is strict
|
|
}
|
|
|
|
@Test("plan: mustStopLocation with no games in that city returns noGamesInRange")
|
|
func plan_mustStopNoGamesInCity_returnsNoGamesInRange() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
let gameDate = startDate.addingTimeInterval(86400 * 2)
|
|
|
|
let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord)
|
|
let bostonGame = makeGame(id: "game-boston", stadiumId: "boston", dateTime: gameDate)
|
|
|
|
// Must stop in Chicago, but no games there
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
mustStopLocations: [LocationInput(name: "Chicago", coordinate: chicagoCoord)],
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [bostonGame],
|
|
teams: [:],
|
|
stadiums: ["boston": bostonStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .failure(let failure) = result else {
|
|
Issue.record("Expected failure when must-stop city has no games")
|
|
return
|
|
}
|
|
#expect(failure.reason == .noGamesInRange)
|
|
}
|
|
|
|
// MARK: - Specification Tests: Successful Planning
|
|
|
|
@Test("plan: single game in range returns success with one option")
|
|
func plan_singleGame_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: .dateRange,
|
|
sports: [.mlb],
|
|
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 game")
|
|
return
|
|
}
|
|
#expect(!options.isEmpty)
|
|
#expect(options.first?.stops.first?.games.contains("game1") == true)
|
|
}
|
|
|
|
@Test("plan: multiple games at same stadium creates single stop")
|
|
func plan_multipleGamesAtSameStadium_createsSingleStop() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let game1 = makeGame(id: "game1", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400))
|
|
let game2 = makeGame(id: "game2", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game1, game2],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
guard case .success(let options) = result else {
|
|
Issue.record("Expected success")
|
|
return
|
|
}
|
|
|
|
// Both games at same stadium should be grouped into one stop
|
|
if let firstOption = options.first {
|
|
let nycStops = firstOption.stops.filter { $0.city == "New York" }
|
|
// Should have 1 stop with 2 games (not 2 stops)
|
|
let totalGamesInNYC = nycStops.flatMap { $0.games }.count
|
|
#expect(totalGamesInNYC >= 2, "Both games should be in the route")
|
|
}
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: returned games are within date range")
|
|
func invariant_returnedGamesWithinDateRange() {
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let stadium = makeStadium(id: "stadium1", city: "New York", coordinate: nycCoord)
|
|
let gameInRange = makeGame(id: "in-range", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2))
|
|
let gameOutOfRange = makeGame(id: "out-of-range", stadiumId: "stadium1", dateTime: endDate.addingTimeInterval(86400 * 10))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 1
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [gameInRange, gameOutOfRange],
|
|
teams: [:],
|
|
stadiums: ["stadium1": stadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } }
|
|
#expect(allGameIds.contains("in-range"))
|
|
#expect(!allGameIds.contains("out-of-range"), "Game outside date range should not be included")
|
|
}
|
|
}
|
|
|
|
@Test("Invariant: A-B-A creates 3 stops not 2")
|
|
func invariant_visitSameCityTwice_createsThreeStops() {
|
|
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)
|
|
|
|
// NYC -> Boston -> NYC sequence
|
|
let game1 = makeGame(id: "nyc1", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400))
|
|
let game2 = makeGame(id: "boston1", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 3))
|
|
let game3 = makeGame(id: "nyc2", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5))
|
|
|
|
var prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
leisureLevel: .moderate,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: 2 // More drivers to ensure feasibility
|
|
)
|
|
prefs.allowRepeatCities = true
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game1, game2, game3],
|
|
teams: [:],
|
|
stadiums: ["nyc": nycStadium, "boston": bostonStadium]
|
|
)
|
|
|
|
let result = planner.plan(request: request)
|
|
|
|
if case .success(let options) = result {
|
|
// Look for an option that includes all 3 games
|
|
let optionWithAllGames = options.first { option in
|
|
let allGames = option.stops.flatMap { $0.games }
|
|
return allGames.contains("nyc1") && allGames.contains("boston1") && allGames.contains("nyc2")
|
|
}
|
|
|
|
if let option = optionWithAllGames {
|
|
// NYC appears first and last, so should have at least 3 stops
|
|
#expect(option.stops.count >= 3, "A-B-A pattern should create 3 stops")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 = makeGame(id: "game1", stadiumId: "stadium1", dateTime: startDate.addingTimeInterval(86400 * 2))
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
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 at least one option")
|
|
for option in options {
|
|
#expect(!option.stops.isEmpty, "Each option must have at least one stop")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|