diff --git a/docs/plans/2026-01-16-test-rewrite-design.md b/docs/plans/2026-01-16-test-rewrite-design.md new file mode 100644 index 0000000..9463638 --- /dev/null +++ b/docs/plans/2026-01-16-test-rewrite-design.md @@ -0,0 +1,411 @@ +# 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.. 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 = [.mlb], + startDate: Date = Date(), + endDate: Date = Date().addingTimeInterval(86400 * 7), + regions: Set = [.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