This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
242 lines
8.9 KiB
Swift
242 lines
8.9 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: String = "stadium_test_\(UUID().uuidString)",
|
|
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: String = "game_test_\(UUID().uuidString)",
|
|
stadiumId: String,
|
|
homeTeamId: String = "team_test_\(UUID().uuidString)",
|
|
awayTeamId: String = "team_test_\(UUID().uuidString)",
|
|
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: [String: 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 = "stadium_1_\(UUID().uuidString)"
|
|
let stadium2Id = "stadium_2_\(UUID().uuidString)"
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|