test(planning): complete test suite with Phase 11 edge cases
Implement comprehensive test infrastructure and all 124 tests across 11 phases: - Phase 0: Test infrastructure (fixtures, mocks, helpers) - Phases 1-10: Core planning engine tests (previously implemented) - Phase 11: Edge case omnibus (11 new tests) - Data edge cases: nil stadiums, malformed dates, invalid coordinates - Boundary conditions: driving limits, radius boundaries - Time zone cases: cross-timezone games, DST transitions Reorganize test structure under Planning/ directory with proper organization. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
468
SportsTimeTests/Planning/ScenarioAPlannerTests.swift
Normal file
468
SportsTimeTests/Planning/ScenarioAPlannerTests.swift
Normal file
@@ -0,0 +1,468 @@
|
||||
//
|
||||
// ScenarioAPlannerTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Phase 4: ScenarioAPlanner Tests
|
||||
// Scenario A: User provides dates, planner finds games.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
@Suite("ScenarioAPlanner Tests")
|
||||
struct ScenarioAPlannerTests {
|
||||
|
||||
// MARK: - Test Fixtures
|
||||
|
||||
private let calendar = Calendar.current
|
||||
private let planner = ScenarioAPlanner()
|
||||
|
||||
/// Creates a date with specific year/month/day/hour
|
||||
private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
components.minute = 0
|
||||
return calendar.date(from: components)!
|
||||
}
|
||||
|
||||
/// Creates a stadium at a known location
|
||||
private func makeStadium(
|
||||
id: UUID = UUID(),
|
||||
city: String,
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
sport: Sport = .mlb
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
name: "\(city) Stadium",
|
||||
city: city,
|
||||
state: "ST",
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
capacity: 40000,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a game at a stadium
|
||||
private func makeGame(
|
||||
id: UUID = UUID(),
|
||||
stadiumId: UUID,
|
||||
homeTeamId: UUID = UUID(),
|
||||
awayTeamId: UUID = UUID(),
|
||||
dateTime: Date,
|
||||
sport: Sport = .mlb
|
||||
) -> Game {
|
||||
Game(
|
||||
id: id,
|
||||
homeTeamId: homeTeamId,
|
||||
awayTeamId: awayTeamId,
|
||||
stadiumId: stadiumId,
|
||||
dateTime: dateTime,
|
||||
sport: sport,
|
||||
season: "2026"
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a PlanningRequest with the given parameters
|
||||
private func makePlanningRequest(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
teams: [UUID: Team] = [:],
|
||||
allowRepeatCities: Bool = true,
|
||||
numberOfDrivers: Int = 1,
|
||||
maxDrivingHoursPerDriver: Double = 8.0
|
||||
) -> PlanningRequest {
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
sports: [.mlb],
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
leisureLevel: .moderate,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
allowRepeatCities: allowRepeatCities
|
||||
)
|
||||
|
||||
return PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: games,
|
||||
teams: teams,
|
||||
stadiums: stadiums
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 4A: Valid Inputs
|
||||
|
||||
@Test("4.1 - Valid date range returns games in range")
|
||||
func test_planByDates_ValidDateRange_ReturnsGamesInRange() {
|
||||
// Setup: 3 games across nearby cities over 5 days
|
||||
let chicagoId = UUID()
|
||||
let milwaukeeId = UUID()
|
||||
let detroitId = UUID()
|
||||
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||||
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
||||
|
||||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit]
|
||||
|
||||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||||
let game3 = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [game1, game2, game3],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify
|
||||
#expect(result.isSuccess, "Should succeed with valid date range and games")
|
||||
#expect(!result.options.isEmpty, "Should return at least one itinerary option")
|
||||
|
||||
// All returned games should be within date range
|
||||
for option in result.options {
|
||||
#expect(option.stops.allSatisfy { !$0.games.isEmpty || $0.city.isEmpty == false },
|
||||
"Each option should have valid stops")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.2 - Single day range returns games on that day")
|
||||
func test_planByDates_SingleDayRange_ReturnsGamesOnThatDay() {
|
||||
// Setup: Multiple games on a single day at the same stadium
|
||||
let stadiumId = UUID()
|
||||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [stadiumId: stadium]
|
||||
|
||||
// Doubleheader on June 5
|
||||
let game1 = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 5, hour: 13))
|
||||
let game2 = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 5, hour: 19))
|
||||
|
||||
// Game outside the range
|
||||
let gameOutside = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 6, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 5, hour: 0),
|
||||
endDate: makeDate(day: 5, hour: 23),
|
||||
games: [game1, game2, gameOutside],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify
|
||||
#expect(result.isSuccess, "Should succeed for single day range")
|
||||
#expect(!result.options.isEmpty, "Should return at least one option")
|
||||
|
||||
// Games in options should only be from June 5
|
||||
if let firstOption = result.options.first {
|
||||
let gameIds = Set(firstOption.stops.flatMap { $0.games })
|
||||
#expect(gameIds.contains(game1.id) || gameIds.contains(game2.id),
|
||||
"Should include games from the single day")
|
||||
#expect(!gameIds.contains(gameOutside.id),
|
||||
"Should not include games outside the date range")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.3 - Multi-week range returns multiple games")
|
||||
func test_planByDates_MultiWeekRange_ReturnsMultipleGames() {
|
||||
// Setup: Games spread across 3 weeks in nearby cities
|
||||
let chicagoId = UUID()
|
||||
let milwaukeeId = UUID()
|
||||
let detroitId = UUID()
|
||||
let clevelandId = UUID()
|
||||
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||||
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
||||
let cleveland = makeStadium(id: clevelandId, city: "Cleveland", lat: 41.4993, lon: -81.6944)
|
||||
|
||||
let stadiums = [
|
||||
chicagoId: chicago,
|
||||
milwaukeeId: milwaukee,
|
||||
detroitId: detroit,
|
||||
clevelandId: cleveland
|
||||
]
|
||||
|
||||
// Games across 3 weeks
|
||||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 1, hour: 19))
|
||||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 5, hour: 19))
|
||||
let game3 = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 10, hour: 19))
|
||||
let game4 = makeGame(stadiumId: clevelandId, dateTime: makeDate(day: 15, hour: 19))
|
||||
let game5 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 20, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 1, hour: 0),
|
||||
endDate: makeDate(day: 21, hour: 23),
|
||||
games: [game1, game2, game3, game4, game5],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify
|
||||
#expect(result.isSuccess, "Should succeed for multi-week range")
|
||||
#expect(!result.options.isEmpty, "Should return itinerary options")
|
||||
|
||||
// Should have options with multiple games
|
||||
let optionWithMultipleGames = result.options.first { $0.totalGames >= 2 }
|
||||
#expect(optionWithMultipleGames != nil, "Should have options covering multiple games")
|
||||
}
|
||||
|
||||
// MARK: - 4B: Edge Cases
|
||||
|
||||
@Test("4.4 - No games in range returns failure")
|
||||
func test_planByDates_NoGamesInRange_ThrowsError() {
|
||||
// Setup: Games outside the requested date range
|
||||
let stadiumId = UUID()
|
||||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [stadiumId: stadium]
|
||||
|
||||
// Games in July, but request is for June
|
||||
let gameOutside1 = makeGame(stadiumId: stadiumId, dateTime: makeDate(month: 7, day: 10, hour: 19))
|
||||
let gameOutside2 = makeGame(stadiumId: stadiumId, dateTime: makeDate(month: 7, day: 15, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(month: 6, day: 1, hour: 0),
|
||||
endDate: makeDate(month: 6, day: 30, hour: 23),
|
||||
games: [gameOutside1, gameOutside2],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should fail with noGamesInRange
|
||||
#expect(!result.isSuccess, "Should fail when no games in range")
|
||||
#expect(result.failure?.reason == .noGamesInRange,
|
||||
"Should return noGamesInRange failure reason")
|
||||
}
|
||||
|
||||
@Test("4.5 - End date before start date returns failure")
|
||||
func test_planByDates_EndDateBeforeStartDate_ThrowsError() {
|
||||
// Setup: Invalid date range where end < start
|
||||
let stadiumId = UUID()
|
||||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [stadiumId: stadium]
|
||||
|
||||
let game = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 5, hour: 19))
|
||||
|
||||
// End date before start date
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 15, hour: 0), // June 15
|
||||
endDate: makeDate(day: 5, hour: 23), // June 5 (before start)
|
||||
games: [game],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: Should fail with missingDateRange (invalid range)
|
||||
#expect(!result.isSuccess, "Should fail when end date is before start date")
|
||||
#expect(result.failure?.reason == .missingDateRange,
|
||||
"Should return missingDateRange for invalid date range")
|
||||
}
|
||||
|
||||
@Test("4.6 - Single game in range returns single game route")
|
||||
func test_planByDates_SingleGameInRange_ReturnsSingleGameRoute() {
|
||||
// Setup: Only one game in the date range
|
||||
let stadiumId = UUID()
|
||||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let stadiums = [stadiumId: stadium]
|
||||
|
||||
let game = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 10, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 5, hour: 0),
|
||||
endDate: makeDate(day: 15, hour: 23),
|
||||
games: [game],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify
|
||||
#expect(result.isSuccess, "Should succeed with single game")
|
||||
#expect(!result.options.isEmpty, "Should return at least one option")
|
||||
|
||||
if let firstOption = result.options.first {
|
||||
#expect(firstOption.totalGames == 1, "Should have exactly 1 game")
|
||||
#expect(firstOption.stops.count == 1, "Should have exactly 1 stop")
|
||||
#expect(firstOption.stops.first?.games.contains(game.id) == true,
|
||||
"Stop should contain the single game")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.7 - Max games in range handles gracefully", .timeLimit(.minutes(5)))
|
||||
func test_planByDates_MaxGamesInRange_HandlesGracefully() {
|
||||
// Setup: Generate 10K games using fixture generator
|
||||
let config = FixtureGenerator.Configuration(
|
||||
seed: 42,
|
||||
gameCount: 10000,
|
||||
stadiumCount: 30,
|
||||
teamCount: 60,
|
||||
dateRange: makeDate(day: 1, hour: 0)...makeDate(month: 9, day: 30, hour: 23),
|
||||
geographicSpread: .nationwide
|
||||
)
|
||||
let data = FixtureGenerator.generate(with: config)
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 1, hour: 0),
|
||||
endDate: makeDate(month: 9, day: 30, hour: 23),
|
||||
games: data.games,
|
||||
stadiums: data.stadiumsById
|
||||
)
|
||||
|
||||
// Execute with timing
|
||||
let startTime = Date()
|
||||
let result = planner.plan(request: request)
|
||||
let elapsed = Date().timeIntervalSince(startTime)
|
||||
|
||||
// Verify: Should complete without crash/hang
|
||||
#expect(elapsed < TestConstants.performanceTimeout,
|
||||
"Should complete within performance timeout")
|
||||
|
||||
// Should produce some result (success or failure is acceptable)
|
||||
// The key is that it doesn't crash or hang
|
||||
if result.isSuccess {
|
||||
#expect(!result.options.isEmpty, "If success, should have options")
|
||||
}
|
||||
// Failure is also acceptable for extreme scale (e.g., no valid routes)
|
||||
}
|
||||
|
||||
// MARK: - 4C: Integration with DAG
|
||||
|
||||
@Test("4.8 - Uses DAG router for routing")
|
||||
func test_planByDates_UsesDAGRouterForRouting() {
|
||||
// Setup: Games that require DAG routing logic
|
||||
// Create games in multiple cities with feasible transitions
|
||||
let chicagoId = UUID()
|
||||
let milwaukeeId = UUID()
|
||||
let detroitId = UUID()
|
||||
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
|
||||
let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458)
|
||||
|
||||
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit]
|
||||
|
||||
// Games that can form a sensible route: Chicago → Milwaukee → Detroit
|
||||
let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19))
|
||||
let game2 = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19))
|
||||
let game3 = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19))
|
||||
|
||||
let request = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 10, hour: 23),
|
||||
games: [game1, game2, game3],
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Execute
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Verify: DAG router should produce routes
|
||||
#expect(result.isSuccess, "Should succeed with routable games")
|
||||
#expect(!result.options.isEmpty, "Should produce routes")
|
||||
|
||||
// Verify routes are in chronological order (DAG property)
|
||||
for option in result.options {
|
||||
// Stops should be in order that respects game times
|
||||
var previousGameDate: Date?
|
||||
for stop in option.stops {
|
||||
if let firstGameId = stop.games.first,
|
||||
let game = [game1, game2, game3].first(where: { $0.id == firstGameId }) {
|
||||
if let prev = previousGameDate {
|
||||
#expect(game.startTime >= prev,
|
||||
"Games should be in chronological order (DAG property)")
|
||||
}
|
||||
previousGameDate = game.startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("4.9 - Respects driver constraints")
|
||||
func test_planByDates_RespectsDriverConstraints() {
|
||||
// Setup: Games that would require excessive daily driving if constraints are loose
|
||||
let nycId = UUID()
|
||||
let chicagoId = UUID()
|
||||
|
||||
// NYC to Chicago is ~790 miles (~13 hours of driving)
|
||||
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
|
||||
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||||
|
||||
let stadiums = [nycId: nyc, chicagoId: chicago]
|
||||
|
||||
// Games on consecutive days - can't drive 790 miles in 8 hours (single driver)
|
||||
let game1 = makeGame(stadiumId: nycId, dateTime: makeDate(day: 5, hour: 14))
|
||||
let game2 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 6, hour: 19))
|
||||
|
||||
// Test with strict constraints (1 driver, 8 hours max)
|
||||
let strictRequest = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 7, hour: 23),
|
||||
games: [game1, game2],
|
||||
stadiums: stadiums,
|
||||
numberOfDrivers: 1,
|
||||
maxDrivingHoursPerDriver: 8.0
|
||||
)
|
||||
|
||||
let strictResult = planner.plan(request: strictRequest)
|
||||
|
||||
// With strict constraints, should NOT have a route with both games on consecutive days
|
||||
if strictResult.isSuccess {
|
||||
let hasConsecutiveDayRoute = strictResult.options.contains { option in
|
||||
option.totalGames == 2 && option.stops.count == 2
|
||||
}
|
||||
// If there's a 2-game route, verify it has adequate travel time
|
||||
if hasConsecutiveDayRoute, let twoGameOption = strictResult.options.first(where: { $0.totalGames == 2 }) {
|
||||
// With only 1 day between games, ~13 hours of driving is too much for 8hr/day limit
|
||||
// The route should either not exist or have adequate travel days
|
||||
let totalHours = twoGameOption.totalDrivingHours
|
||||
let daysAvailable = 1.0 // Only 1 day between games
|
||||
let hoursPerDay = totalHours / daysAvailable
|
||||
|
||||
// This assertion is soft - the router may reject this route entirely
|
||||
#expect(hoursPerDay <= 8.0 || !hasConsecutiveDayRoute,
|
||||
"Route should respect daily driving limits")
|
||||
}
|
||||
}
|
||||
|
||||
// Test with relaxed constraints (2 drivers = 16 hours max per day)
|
||||
let relaxedRequest = makePlanningRequest(
|
||||
startDate: makeDate(day: 4, hour: 0),
|
||||
endDate: makeDate(day: 7, hour: 23),
|
||||
games: [game1, game2],
|
||||
stadiums: stadiums,
|
||||
numberOfDrivers: 2,
|
||||
maxDrivingHoursPerDriver: 8.0
|
||||
)
|
||||
|
||||
let relaxedResult = planner.plan(request: relaxedRequest)
|
||||
|
||||
// With 2 drivers (16 hours/day), the trip becomes more feasible
|
||||
// Note: 790 miles at 60mph is ~13 hours, which fits in 16 hours
|
||||
if relaxedResult.isSuccess {
|
||||
// Should have more routing options with relaxed constraints
|
||||
#expect(relaxedResult.options.count >= 1,
|
||||
"Should have options with relaxed driver constraints")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user