Files
Sportstime/SportsTimeTests/Planning/ConcurrencyTests.swift
Trey t 1bd248c255 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>
2026-01-11 01:14:40 -06:00

242 lines
8.8 KiB
Swift

//
// ConcurrencyTests.swift
// SportsTimeTests
//
// Phase 10: Concurrency Tests
// Documents current thread-safety behavior for future refactoring reference.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("Concurrency Tests", .serialized)
struct ConcurrencyTests {
// MARK: - Test Fixtures
private let calendar = Calendar.current
/// 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 for Scenario A (date range only)
private func makeScenarioARequest(
startDate: Date,
endDate: Date,
games: [Game],
stadiums: [UUID: Stadium]
) -> PlanningRequest {
let preferences = TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
numberOfDrivers: 1,
maxDrivingHoursPerDriver: 8.0,
allowRepeatCities: true
)
return PlanningRequest(
preferences: preferences,
availableGames: games,
teams: [:],
stadiums: stadiums
)
}
/// Creates a valid test request with nearby cities
private func makeValidTestRequest(requestIndex: Int) -> PlanningRequest {
// Use different but nearby city pairs for each request to create variety
let cityPairs: [(city1: (String, Double, Double), city2: (String, Double, Double))] = [
(("Chicago", 41.8781, -87.6298), ("Milwaukee", 43.0389, -87.9065)),
(("New York", 40.7128, -73.9352), ("Philadelphia", 39.9526, -75.1652)),
(("Boston", 42.3601, -71.0589), ("Providence", 41.8240, -71.4128)),
(("Los Angeles", 34.0522, -118.2437), ("San Diego", 32.7157, -117.1611)),
(("Seattle", 47.6062, -122.3321), ("Portland", 45.5152, -122.6784)),
]
let pair = cityPairs[requestIndex % cityPairs.count]
let stadium1Id = UUID()
let stadium2Id = UUID()
let stadium1 = makeStadium(id: stadium1Id, city: pair.city1.0, lat: pair.city1.1, lon: pair.city1.2)
let stadium2 = makeStadium(id: stadium2Id, city: pair.city2.0, lat: pair.city2.1, lon: pair.city2.2)
let stadiums = [stadium1Id: stadium1, stadium2Id: stadium2]
// Games on different days for feasible routing
let baseDay = 5 + (requestIndex * 2) % 20
let game1 = makeGame(stadiumId: stadium1Id, dateTime: makeDate(day: baseDay, hour: 19))
let game2 = makeGame(stadiumId: stadium2Id, dateTime: makeDate(day: baseDay + 2, hour: 19))
return makeScenarioARequest(
startDate: makeDate(day: baseDay - 1, hour: 0),
endDate: makeDate(day: baseDay + 5, hour: 23),
games: [game1, game2],
stadiums: stadiums
)
}
// MARK: - 10.1: Concurrent Requests Test
@Test("10.1 - Concurrent requests behavior documentation")
func test_engine_ConcurrentRequests_CurrentlyUnsafe() async {
// DOCUMENTATION TEST
// Purpose: Document the current behavior when TripPlanningEngine is called concurrently.
//
// Current Implementation Status:
// - TripPlanningEngine is a `final class` (not an actor)
// - It appears stateless - no mutable instance state persists between calls
// - Each call to planItineraries creates fresh planner instances
//
// Expected Behavior:
// - If truly stateless: concurrent calls should succeed independently
// - If hidden state exists: may see race conditions or crashes
//
// This test documents the current behavior for future refactoring reference.
let concurrentRequestCount = 10
let engine = TripPlanningEngine()
// Create unique requests for each concurrent task
let requests = (0..<concurrentRequestCount).map { makeValidTestRequest(requestIndex: $0) }
// Execute all requests concurrently using TaskGroup
let results = await withTaskGroup(of: (Int, ItineraryResult).self) { group in
for (index, request) in requests.enumerated() {
group.addTask {
let result = engine.planItineraries(request: request)
return (index, result)
}
}
var collected: [(Int, ItineraryResult)] = []
for await result in group {
collected.append(result)
}
return collected.sorted { $0.0 < $1.0 }
}
// Document the observed behavior
let successCount = results.filter { $0.1.isSuccess }.count
let failureCount = results.filter { !$0.1.isSuccess }.count
// Current observation: Engine appears stateless, so concurrent calls should work
// If this test fails in the future, it indicates hidden shared state was introduced
// We expect most/all requests to succeed since the engine is stateless
// Allow for some failures due to specific request constraints
#expect(results.count == concurrentRequestCount,
"All concurrent requests should complete (got \(results.count)/\(concurrentRequestCount))")
// Document: Current implementation handles concurrent requests
// because planItineraries() creates fresh planners per call
#expect(successCount > 0,
"At least some concurrent requests should succeed (success: \(successCount), failure: \(failureCount))")
// Note for future refactoring:
// If actor-based refactoring is done, update this test to verify:
// 1. Proper isolation of mutable state
// 2. Consistent results regardless of concurrent access
// 3. No deadlocks under high concurrency
}
// MARK: - 10.2: Sequential Requests Test
@Test("10.2 - Sequential requests succeed consistently")
func test_engine_SequentialRequests_Succeeds() {
// BASELINE TEST
// Purpose: Verify that sequential requests to the same engine instance
// always succeed when given valid input.
//
// This establishes the baseline behavior that any concurrency-safe
// refactoring must preserve.
let sequentialRequestCount = 10
let engine = TripPlanningEngine()
var results: [ItineraryResult] = []
// Execute requests sequentially
for index in 0..<sequentialRequestCount {
let request = makeValidTestRequest(requestIndex: index)
let result = engine.planItineraries(request: request)
results.append(result)
}
// All sequential requests should complete
#expect(results.count == sequentialRequestCount,
"All sequential requests should complete")
// With valid input, all requests should succeed
let successCount = results.filter { $0.isSuccess }.count
#expect(successCount == sequentialRequestCount,
"All sequential requests with valid input should succeed (got \(successCount)/\(sequentialRequestCount))")
// Verify each successful result has valid data
for (index, result) in results.enumerated() {
if result.isSuccess {
#expect(!result.options.isEmpty,
"Request \(index) should return at least one route option")
for option in result.options {
#expect(!option.stops.isEmpty,
"Request \(index) options should have stops")
}
}
}
}
}