Comprehensive plan to delete broken tests and create new Swift Testing coverage for Planning Engine, Domain Models, and Services with parallel execution across multiple simulators. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
412 lines
11 KiB
Markdown
412 lines
11 KiB
Markdown
# Unit Test Rewrite Design
|
|
|
|
## Goal
|
|
|
|
Delete all existing broken tests and create comprehensive new test coverage using Swift Testing framework, covering all edge cases for Planning Engine, Domain Models, and Services.
|
|
|
|
## Requirements
|
|
|
|
- **Framework:** Swift Testing (`@Test`, `#expect`, `@Suite`)
|
|
- **Structure:** Mirror source folder layout
|
|
- **Parallelization:** Tests run in parallel across multiple simulator devices
|
|
- **Coverage:** ~52 source files, ~150-200 tests total
|
|
|
|
## Test Structure
|
|
|
|
```
|
|
SportsTimeTests/
|
|
├── Planning/
|
|
│ ├── TripPlanningEngineTests.swift
|
|
│ ├── GameDAGRouterTests.swift
|
|
│ ├── ItineraryBuilderTests.swift
|
|
│ ├── RouteFiltersTests.swift
|
|
│ ├── TravelEstimatorTests.swift
|
|
│ ├── ScenarioPlannerTests.swift
|
|
│ ├── ScenarioAPlannerTests.swift
|
|
│ ├── ScenarioBPlannerTests.swift
|
|
│ ├── ScenarioCPlannerTests.swift
|
|
│ ├── ScenarioDPlannerTests.swift
|
|
│ └── PlanningModelsTests.swift
|
|
├── Domain/
|
|
│ ├── GameTests.swift
|
|
│ ├── TripTests.swift
|
|
│ ├── TripStopTests.swift
|
|
│ ├── StadiumTests.swift
|
|
│ ├── TeamTests.swift
|
|
│ ├── SportTests.swift
|
|
│ ├── RegionTests.swift
|
|
│ ├── DivisionTests.swift
|
|
│ ├── TravelSegmentTests.swift
|
|
│ ├── ProgressTests.swift
|
|
│ ├── TripPreferencesTests.swift
|
|
│ ├── DynamicSportTests.swift
|
|
│ ├── TripPollTests.swift
|
|
│ ├── AnySportTests.swift
|
|
│ └── AchievementDefinitionsTests.swift
|
|
├── Services/
|
|
│ ├── CloudKitServiceTests.swift
|
|
│ ├── DataProviderTests.swift
|
|
│ ├── LocationServiceTests.swift
|
|
│ ├── AchievementEngineTests.swift
|
|
│ ├── PollServiceTests.swift
|
|
│ ├── GameMatcherTests.swift
|
|
│ ├── EVChargingServiceTests.swift
|
|
│ ├── StadiumProximityMatcherTests.swift
|
|
│ ├── RouteDescriptionGeneratorTests.swift
|
|
│ ├── SuggestedTripsGeneratorTests.swift
|
|
│ └── (remaining services...)
|
|
└── Helpers/
|
|
├── TestFixtures.swift
|
|
└── MockServices.swift
|
|
```
|
|
|
|
## Parallel Execution Configuration
|
|
|
|
### Xcode Test Plan (`SportsTimeTests.xctestplan`)
|
|
|
|
```json
|
|
{
|
|
"configurations": [
|
|
{
|
|
"name": "Parallel Configuration",
|
|
"options": {
|
|
"targetForVariableExpansion": {
|
|
"containerPath": "container:SportsTime.xcodeproj",
|
|
"identifier": "SportsTimeTests"
|
|
},
|
|
"maximumParallelSimulators": 4,
|
|
"parallelExecutionEnabled": true
|
|
}
|
|
}
|
|
],
|
|
"defaultOptions": {
|
|
"maximumParallelSimulators": 4,
|
|
"parallelExecutionEnabled": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Command Line Parallel Execution
|
|
|
|
```bash
|
|
# Run tests in parallel across 4 simulators
|
|
xcodebuild test \
|
|
-project SportsTime.xcodeproj \
|
|
-scheme SportsTime \
|
|
-parallel-testing-enabled YES \
|
|
-parallel-testing-worker-count 4 \
|
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
|
|
-destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)'
|
|
```
|
|
|
|
### Swift Testing Parallelization
|
|
|
|
Swift Testing runs tests in parallel by default. For tests that must run serially (shared state), use:
|
|
|
|
```swift
|
|
@Suite(.serialized)
|
|
struct DatabaseTests {
|
|
// These tests run one at a time
|
|
}
|
|
```
|
|
|
|
## Test Patterns
|
|
|
|
### Standard Test Structure
|
|
|
|
```swift
|
|
import Testing
|
|
@testable import SportsTime
|
|
|
|
@Suite("GameDAGRouter")
|
|
struct GameDAGRouterTests {
|
|
|
|
// MARK: - findRoutes
|
|
|
|
@Test("Returns valid routes for standard input")
|
|
func findRoutes_validInput_returnsRoutes() async {
|
|
let games = TestFixtures.games(count: 5)
|
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
maxDailyDrivingMiles: 500,
|
|
beamWidth: 10
|
|
)
|
|
|
|
#expect(!routes.isEmpty)
|
|
#expect(routes.allSatisfy { $0.count <= games.count })
|
|
}
|
|
|
|
@Test("Returns empty for empty game list")
|
|
func findRoutes_emptyGames_returnsEmpty() async {
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [],
|
|
stadiums: [:],
|
|
maxDailyDrivingMiles: 500,
|
|
beamWidth: 10
|
|
)
|
|
|
|
#expect(routes.isEmpty)
|
|
}
|
|
|
|
@Test("Handles single game")
|
|
func findRoutes_singleGame_returnsSingleRoute() async {
|
|
let game = TestFixtures.game()
|
|
let stadiums = TestFixtures.stadiumMap(for: [game])
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: [game],
|
|
stadiums: stadiums,
|
|
maxDailyDrivingMiles: 500,
|
|
beamWidth: 10
|
|
)
|
|
|
|
#expect(routes.count == 1)
|
|
#expect(routes.first?.count == 1)
|
|
}
|
|
|
|
// MARK: - Parameterized Scale Tests
|
|
|
|
@Test("Scales with game count", arguments: [5, 10, 25, 50, 100])
|
|
func findRoutes_scales(gameCount: Int) async {
|
|
let games = TestFixtures.games(count: gameCount)
|
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
|
|
|
let routes = GameDAGRouter.findRoutes(
|
|
games: games,
|
|
stadiums: stadiums,
|
|
maxDailyDrivingMiles: 500,
|
|
beamWidth: 10
|
|
)
|
|
|
|
#expect(routes.count > 0)
|
|
}
|
|
|
|
// MARK: - canTransition Edge Cases
|
|
|
|
@Test("Rejects impossible same-day transitions")
|
|
func canTransition_impossibleSameDay_returnsFalse() async {
|
|
let nyGame = TestFixtures.game(city: "New York", date: Date())
|
|
let laGame = TestFixtures.game(city: "Los Angeles", date: Date())
|
|
|
|
let canTransit = GameDAGRouter.canTransition(
|
|
from: nyGame,
|
|
to: laGame,
|
|
maxDailyDrivingMiles: 500
|
|
)
|
|
|
|
#expect(!canTransit)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Fixtures
|
|
|
|
```swift
|
|
// TestFixtures.swift
|
|
import Foundation
|
|
@testable import SportsTime
|
|
|
|
enum TestFixtures {
|
|
|
|
// MARK: - Games
|
|
|
|
static func game(
|
|
id: String = UUID().uuidString,
|
|
sport: Sport = .mlb,
|
|
city: String = "New York",
|
|
date: Date = Date(),
|
|
homeTeamId: String = "nyy",
|
|
awayTeamId: String = "bos"
|
|
) -> Game {
|
|
Game(
|
|
id: id,
|
|
sport: sport,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId,
|
|
stadiumId: "\(city.lowercased())-stadium",
|
|
dateTime: date
|
|
)
|
|
}
|
|
|
|
static func games(count: Int, spread: TimeInterval = 86400) -> [Game] {
|
|
(0..<count).map { i in
|
|
game(
|
|
id: "game-\(i)",
|
|
date: Date().addingTimeInterval(Double(i) * spread)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stadiums
|
|
|
|
static func stadium(
|
|
id: String = UUID().uuidString,
|
|
name: String = "Test Stadium",
|
|
city: String = "New York",
|
|
latitude: Double = 40.7128,
|
|
longitude: Double = -74.0060
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: name,
|
|
city: city,
|
|
state: "NY",
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
sport: .mlb,
|
|
teamIds: ["test-team"]
|
|
)
|
|
}
|
|
|
|
static func stadiumMap(for games: [Game]) -> [String: Stadium] {
|
|
var map: [String: Stadium] = [:]
|
|
for game in games {
|
|
if map[game.stadiumId] == nil {
|
|
map[game.stadiumId] = stadium(id: game.stadiumId)
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
// MARK: - Trips
|
|
|
|
static func trip(
|
|
id: String = UUID().uuidString,
|
|
stops: [TripStop] = []
|
|
) -> Trip {
|
|
Trip(
|
|
id: id,
|
|
name: "Test Trip",
|
|
stops: stops,
|
|
createdAt: Date(),
|
|
status: .planned
|
|
)
|
|
}
|
|
|
|
// MARK: - Planning Requests
|
|
|
|
static func planningRequest(
|
|
mode: PlanningMode = .dateRange,
|
|
sports: Set<Sport> = [.mlb],
|
|
startDate: Date = Date(),
|
|
endDate: Date = Date().addingTimeInterval(86400 * 7),
|
|
regions: Set<Region> = [.east]
|
|
) -> PlanningRequest {
|
|
PlanningRequest(
|
|
mode: mode,
|
|
sports: sports,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
regions: regions,
|
|
routePreference: .balanced,
|
|
allowRepeatCities: false,
|
|
mustStopLocations: []
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Mock Services
|
|
|
|
```swift
|
|
// MockServices.swift
|
|
import Foundation
|
|
@testable import SportsTime
|
|
|
|
// MARK: - Mock CloudKit Service
|
|
|
|
final class MockCloudKitService: CloudKitServiceProtocol {
|
|
var stubbedGames: [Game] = []
|
|
var stubbedStadiums: [Stadium] = []
|
|
var stubbedTeams: [Team] = []
|
|
var shouldFail = false
|
|
var failureError: Error = NSError(domain: "test", code: -1)
|
|
|
|
func fetchGames() async throws -> [Game] {
|
|
if shouldFail { throw failureError }
|
|
return stubbedGames
|
|
}
|
|
|
|
func fetchStadiums() async throws -> [Stadium] {
|
|
if shouldFail { throw failureError }
|
|
return stubbedStadiums
|
|
}
|
|
|
|
func fetchTeams() async throws -> [Team] {
|
|
if shouldFail { throw failureError }
|
|
return stubbedTeams
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock Location Service
|
|
|
|
final class MockLocationService: LocationServiceProtocol {
|
|
var stubbedDistance: Double = 100.0
|
|
var stubbedTravelTime: TimeInterval = 3600
|
|
var shouldFail = false
|
|
|
|
func calculateDistance(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> Double {
|
|
if shouldFail { throw LocationError.unavailable }
|
|
return stubbedDistance
|
|
}
|
|
|
|
func calculateTravelTime(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> TimeInterval {
|
|
if shouldFail { throw LocationError.unavailable }
|
|
return stubbedTravelTime
|
|
}
|
|
}
|
|
```
|
|
|
|
## Edge Cases by Module
|
|
|
|
### Planning Engine
|
|
|
|
| Function | Edge Cases |
|
|
|----------|------------|
|
|
| `planItineraries` | Empty request, past dates, single day, 30+ days, no matching games |
|
|
| `findRoutes` | 0 games, 1 game, 100+ games, all same city, all different cities |
|
|
| `canTransition` | Same day/different coast, next day/same city, impossible distances |
|
|
| `build` | Empty route, circular route, gaps in dates |
|
|
| `filterRepeatCities` | No repeats, all repeats, alternating cities |
|
|
| `estimate` | Zero distance, cross-country, international coords |
|
|
|
|
### Domain Models
|
|
|
|
| Model | Edge Cases |
|
|
|-------|------------|
|
|
| `Game` | Nil teams, past dates, invalid stadium ID |
|
|
| `Trip` | Empty stops, single stop, duplicate stops |
|
|
| `Stadium` | Edge coordinates (0,0), (90,180), negative values |
|
|
| `Region.classify` | Boundary longitudes (-85, -110), exact boundaries |
|
|
| `Sport` | All enum cases, raw value round-trip |
|
|
|
|
### Services
|
|
|
|
| Service | Edge Cases |
|
|
|---------|------------|
|
|
| `CloudKitService` | Network failure, empty response, partial data, quota exceeded |
|
|
| `DataProvider` | Not configured, empty cache, stale data |
|
|
| `LocationService` | No permission, unavailable, invalid coordinates |
|
|
| `AchievementEngine` | No visits, first visit, duplicate check |
|
|
|
|
## Implementation Order
|
|
|
|
1. **Delete existing tests** - Remove all 38 broken test files
|
|
2. **Create infrastructure** - TestFixtures.swift, MockServices.swift
|
|
3. **Planning tests** (highest priority - core business logic)
|
|
4. **Domain tests** (data integrity)
|
|
5. **Services tests** (integrations)
|
|
|
|
## Success Criteria
|
|
|
|
- All tests pass
|
|
- Tests run in parallel across 4 simulators
|
|
- No flaky tests (deterministic, no timing dependencies)
|
|
- Coverage of all public functions
|
|
- Edge cases documented and tested
|