From 966a580def585ce8e17d5ee79c7920a659690028 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 10:22:25 -0600 Subject: [PATCH] docs: add unit test rewrite design 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 --- docs/plans/2026-01-16-test-rewrite-design.md | 411 +++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 docs/plans/2026-01-16-test-rewrite-design.md 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