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>
This commit is contained in:
Trey t
2026-01-11 01:14:40 -06:00
parent eeaf900e5a
commit 1bd248c255
23 changed files with 7565 additions and 6878 deletions

550
docs/TEST_PLAN.md Normal file
View File

@@ -0,0 +1,550 @@
# Test Suite Implementation Plan
**Scope:** TO-DOS items 20.0 - 20.10
**Approach:** TDD — tests define expected behavior, then app code is updated to pass
**Framework:** Swift Testing (`@Test`, `#expect`, `@Suite`) with XCTest fallback if needed
**Status:** Use checkboxes to track progress
---
## Pre-Implementation Setup
### Phase 0: Test Infrastructure
> Set up foundations before writing any feature tests.
- [x] **0.1** Create `SportsTimeTests/` directory structure:
```
SportsTimeTests/
├── Fixtures/
│ └── FixtureGenerator.swift
├── Mocks/
│ ├── MockCloudKitService.swift
│ ├── MockLocationService.swift
│ └── MockAppDataProvider.swift
├── Helpers/
│ ├── BruteForceRouteVerifier.swift
│ └── TestConstants.swift
├── Planning/
│ ├── GameDAGRouterTests.swift
│ ├── ScenarioAPlannerTests.swift
│ ├── ScenarioBPlannerTests.swift
│ ├── ScenarioCPlannerTests.swift
│ ├── TravelEstimatorTests.swift
│ ├── ItineraryBuilderTests.swift
│ └── TripPlanningEngineTests.swift
└── Performance/
└── PlannerScaleTests.swift
```
- [x] **0.2** Implement `FixtureGenerator.swift`:
- Generate synthetic `Game` objects with configurable:
- Count (5, 50, 500, 2000, 10000)
- Date range
- Geographic spread
- Stadium/team distribution
- Generate synthetic `Stadium` dictionary
- Generate synthetic `Trip` with configurable stops
- Deterministic seeding for reproducible tests
- [x] **0.3** Implement `MockCloudKitService.swift`:
- Stub all public methods
- Return fixture data
- Configurable error injection
- [x] **0.4** Implement `MockLocationService.swift`:
- Stub geocoding
- Stub routing (return pre-calculated distances)
- Configurable latency simulation
- [x] **0.5** Implement `MockAppDataProvider.swift`:
- Return fixture stadiums/teams/games
- Configurable to simulate empty data
- [x] **0.6** Implement `BruteForceRouteVerifier.swift`:
- For inputs with ≤8 stops, exhaustively enumerate all permutations
- Compare engine result against true optimal
- Used to validate "no obviously better route exists"
- [x] **0.7** Implement `TestConstants.swift`:
```swift
enum TestConstants {
static let nearbyRadiusMiles: Double = 50.0
static let performanceTimeout: TimeInterval = 300.0 // 5 min
static let hangTimeout: TimeInterval = 30.0
// Baselines TBD after initial runs
static var baseline500Games: TimeInterval = 0
static var baseline2000Games: TimeInterval = 0
static var baseline10000Games: TimeInterval = 0
}
```
---
## Feature Test Phases
### Phase 1: TravelEstimator Tests
> Foundation — all planners depend on this.
**File:** `TravelEstimatorTests.swift`
- [x] **1.1** `test_haversineDistanceMiles_KnownDistance`
NYC to LA = ~2,451 miles (verify within 1% tolerance)
- [x] **1.2** `test_haversineDistanceMiles_SamePoint_ReturnsZero`
- [x] **1.3** `test_haversineDistanceMiles_Antipodal_ReturnsHalfEarthCircumference`
- [x] **1.4** `test_estimate_NilCoordinates_ReturnsNil`
- [x] **1.5** `test_estimate_ExceedsMaxDailyHours_ReturnsNil`
Distance requiring 3+ days of driving should return nil
- [x] **1.6** `test_estimate_ValidTrip_ReturnsSegment`
Verify `distanceMeters`, `durationSeconds`, `travelMode`
- [x] **1.7** `test_calculateTravelDays_SingleDayDrive`
4 hours driving = 1 day
- [x] **1.8** `test_calculateTravelDays_MultiDayDrive`
20 hours driving = 3 days (ceil(20/8))
- [x] **1.9** `test_estimateFallbackDistance_SameCity_ReturnsZero`
- [x] **1.10** `test_estimateFallbackDistance_DifferentCity_Returns300`
---
### Phase 2: GameDAGRouter Tests — Core Logic
> The "scary to touch" component — extensive edge case coverage.
**File:** `GameDAGRouterTests.swift`
#### 2A: Empty & Single-Element Cases
- [x] **2.1** `test_findRoutes_EmptyGames_ReturnsEmptyArray`
- [x] **2.2** `test_findRoutes_SingleGame_ReturnsSingleRoute`
- [x] **2.3** `test_findRoutes_SingleGame_WithMatchingAnchor_ReturnsSingleRoute`
- [x] **2.4** `test_findRoutes_SingleGame_WithNonMatchingAnchor_ReturnsEmpty`
#### 2B: Two-Game Cases
- [x] **2.5** `test_findRoutes_TwoGames_FeasibleTransition_ReturnsBothInOrder`
- [x] **2.6** `test_findRoutes_TwoGames_InfeasibleTransition_ReturnsSeparateRoutes`
- [x] **2.7** `test_findRoutes_TwoGames_SameStadiumSameDay_Succeeds`
(Doubleheader scenario)
#### 2C: Anchor Game Constraints
- [x] **2.8** `test_findRoutes_WithAnchors_OnlyReturnsRoutesContainingAllAnchors`
- [x] **2.9** `test_findRoutes_ImpossibleAnchors_ReturnsEmpty`
Anchors are geographically/temporally impossible to connect
- [x] **2.10** `test_findRoutes_MultipleAnchors_RouteMustContainAll`
#### 2D: Repeat Cities Toggle
- [x] **2.11** `test_findRoutes_AllowRepeatCities_SameCityMultipleDays_Allowed`
- [x] **2.12** `test_findRoutes_DisallowRepeatCities_SkipsSecondVisit`
- [x] **2.13** `test_findRoutes_DisallowRepeatCities_OnlyOptionIsRepeat_OverridesWithWarning`
**TDD Note:** This test defines a new `Trip.warnings: [PlanningWarning]` property
#### 2E: Driving Constraints
- [x] **2.14** `test_findRoutes_ExceedsMaxDailyDriving_TransitionRejected`
- [x] **2.15** `test_findRoutes_MultiDayDrive_Allowed_IfWithinDailyLimits`
- [x] **2.16** `test_findRoutes_SameDayDifferentStadiums_ChecksAvailableTime`
#### 2F: Calendar Day Logic
- [x] **2.17** `test_findRoutes_MaxDayLookahead_RespectsLimit`
Games > 5 days apart should not connect directly
- [x] **2.18** `test_findRoutes_DSTTransition_HandlesCorrectly`
Spring forward / fall back edge case
- [x] **2.19** `test_findRoutes_MidnightGame_AssignsToCorrectDay`
Game at 12:05 AM belongs to new day
#### 2G: Diversity Selection
- [x] **2.20** `test_selectDiverseRoutes_ShortAndLongTrips_BothRepresented`
- [x] **2.21** `test_selectDiverseRoutes_HighAndLowMileage_BothRepresented`
- [x] **2.22** `test_selectDiverseRoutes_FewAndManyCities_BothRepresented`
- [x] **2.23** `test_selectDiverseRoutes_DuplicateRoutes_Deduplicated`
#### 2H: Cycle Handling
- [x] **2.24** `test_findRoutes_GraphWithPotentialCycle_HandlesSilently`
Verify no infinite loop, returns valid routes
#### 2I: Beam Search Behavior
- [x] **2.25** `test_findRoutes_LargeDataset_ScalesBeamWidth`
Verify `effectiveBeamWidth` kicks in for 5000+ games
- [x] **2.26** `test_findRoutes_EarlyTermination_TriggersWhenBeamFull`
---
### Phase 3: GameDAGRouter Tests — Scale & Performance
> Stress tests for 10K+ objects. Periodic/manual execution.
**File:** `GameDAGRouterTests.swift` (continued) or separate `GameDAGRouterScaleTests.swift`
#### 3A: Scale Tests
- [x] **3.1** `test_findRoutes_5Games_CompletesWithin5Minutes`
- [x] **3.2** `test_findRoutes_50Games_CompletesWithin5Minutes`
- [x] **3.3** `test_findRoutes_500Games_CompletesWithin5Minutes`
- [x] **3.4** `test_findRoutes_2000Games_CompletesWithin5Minutes`
- [x] **3.5** `test_findRoutes_10000Games_CompletesWithin5Minutes`
- [x] **3.6** `test_findRoutes_50000Nodes_CompletesWithin5Minutes`
Stress test — may need timeout adjustment
#### 3B: Performance Baselines
- [x] **3.7** Record baseline times for 500/2000/10000 games (first run)
- [x] **3.8** After baselines established, add regression assertions
#### 3C: Memory Tests
- [x] **3.9** `test_findRoutes_RepeatedCalls_NoMemoryLeak`
Run 100 iterations, verify allocation/deallocation balance
- [x] **3.10** `test_findRoutes_LargeDataset_MemoryBounded`
10K games should not exceed reasonable memory
---
### Phase 4: ScenarioAPlanner Tests (Plan by Dates)
> User provides dates, planner finds games.
**File:** `ScenarioAPlannerTests.swift`
#### 4A: Valid Inputs
- [x] **4.1** `test_planByDates_ValidDateRange_ReturnsGamesInRange`
- [x] **4.2** `test_planByDates_SingleDayRange_ReturnsGamesOnThatDay`
- [x] **4.3** `test_planByDates_MultiWeekRange_ReturnsMultipleGames`
#### 4B: Edge Cases
- [x] **4.4** `test_planByDates_NoGamesInRange_ThrowsError`
**TDD Note:** Uses existing `PlanningFailure.FailureReason.noGamesInRange`
- [x] **4.5** `test_planByDates_EndDateBeforeStartDate_ThrowsError`
- [x] **4.6** `test_planByDates_SingleGameInRange_ReturnsSingleGameRoute`
- [x] **4.7** `test_planByDates_MaxGamesInRange_HandlesGracefully`
10K games in range — verify no crash/hang
#### 4C: Integration with DAG
- [x] **4.8** `test_planByDates_UsesDAGRouterForRouting`
- [x] **4.9** `test_planByDates_RespectsDriverConstraints`
---
### Phase 5: ScenarioBPlanner Tests (Must-See Games)
> User selects specific games, planner builds route.
**File:** `ScenarioBPlannerTests.swift`
#### 5A: Valid Inputs
- [x] **5.1** `test_mustSeeGames_SingleGame_ReturnsTripWithThatGame`
- [x] **5.2** `test_mustSeeGames_MultipleGames_ReturnsOptimalRoute`
- [x] **5.3** `test_mustSeeGames_GamesInDifferentCities_ConnectsThem`
#### 5B: Edge Cases
- [x] **5.4** `test_mustSeeGames_EmptySelection_ThrowsError`
- [x] **5.5** `test_mustSeeGames_ImpossibleToConnect_ThrowsError`
Games on same day in cities 850+ miles apart (same region)
- [x] **5.6** `test_mustSeeGames_MaxGamesSelected_HandlesGracefully`
#### 5C: Optimality Verification
- [x] **5.7** `test_mustSeeGames_SmallInput_MatchesBruteForceOptimal`
≤8 games — verify against `BruteForceRouteVerifier`
- [x] **5.8** `test_mustSeeGames_LargeInput_NoObviouslyBetterRoute`
---
### Phase 6: ScenarioCPlanner Tests (Depart/Return Cities)
> User specifies origin and destination.
**File:** `ScenarioCPlannerTests.swift`
#### 6A: Valid Inputs
- [x] **6.1** `test_departReturn_SameCity_ReturnsRoundTrip`
- [x] **6.2** `test_departReturn_DifferentCities_ReturnsOneWayRoute`
- [x] **6.3** `test_departReturn_GamesAlongCorridor_IncludesNearbyGames`
Uses 50-mile radius
#### 6B: Edge Cases
- [x] **6.4** `test_departReturn_NoGamesAlongRoute_ThrowsError`
- [x] **6.5** `test_departReturn_InvalidCity_ThrowsError`
- [x] **6.6** `test_departReturn_ExtremeDistance_RespectsConstraints`
NYC to LA — verify driving constraints applied
#### 6C: Must-Stop Locations
- [x] **6.7** `test_departReturn_WithMustStopLocation_IncludesStop`
- [x] **6.8** `test_departReturn_MustStopNoNearbyGames_IncludesStopAnyway`
Stop is included but may not have games
- [x] **6.9** `test_departReturn_MultipleMustStops_AllIncluded`
- [x] **6.10** `test_departReturn_MustStopConflictsWithRoute_FindsCompromise`
---
### Phase 7: TripPlanningEngine Integration Tests
> Main orchestrator — tests all scenarios together.
**File:** `TripPlanningEngineTests.swift`
#### 7A: Scenario Routing
- [x] **7.1** `test_engine_ScenarioA_DelegatesCorrectly`
- [x] **7.2** `test_engine_ScenarioB_DelegatesCorrectly`
- [x] **7.3** `test_engine_ScenarioC_DelegatesCorrectly`
- [x] **7.4** `test_engine_ScenariosAreMutuallyExclusive`
Cannot mix scenarios in single request
#### 7B: Result Structure
- [x] **7.5** `test_engine_Result_ContainsTravelSegments`
- [x] **7.6** `test_engine_Result_ContainsItineraryDays`
- [x] **7.7** `test_engine_Result_IncludesWarnings_WhenApplicable`
**TDD Note:** Validates `Trip.warnings` property exists
#### 7C: Constraint Application
- [x] **7.8** `test_engine_NumberOfDrivers_AffectsMaxDailyDriving`
More drivers = can drive more per day
- [x] **7.9** `test_engine_MaxDrivingPerDay_Respected`
- [x] **7.10** `test_engine_AllowRepeatCities_PropagatedToDAG`
#### 7D: Error Handling
- [x] **7.11** `test_engine_ImpossibleConstraints_ReturnsNoResult`
- [x] **7.12** `test_engine_EmptyInput_ThrowsError`
---
### Phase 8: ItineraryBuilder Tests
> Builds day-by-day itinerary from route.
**File:** `ItineraryBuilderTests.swift`
- [x] **8.1** `test_builder_SingleGame_CreatesSingleDay`
- [x] **8.2** `test_builder_MultiCity_CreatesTravelSegmentsBetween`
- [x] **8.3** `test_builder_SameCity_MultipleGames_GroupsOnSameDay`
- [x] **8.4** `test_builder_TravelDays_InsertedWhenDrivingExceeds8Hours`
- [x] **8.5** `test_builder_ArrivalTimeBeforeGame_Calculated`
- [x] **8.6** `test_builder_EmptyRoute_ReturnsEmptyItinerary`
---
### Phase 9: RouteFilters Tests
> Filtering on All Trips list.
**File:** `RouteFiltersTests.swift`
#### 9A: Single Filters
- [x] **9.1** `test_filterBySport_SingleSport_ReturnsMatching`
- [x] **9.2** `test_filterBySport_MultipleSports_ReturnsUnion`
- [x] **9.3** `test_filterBySport_AllSports_ReturnsAll`
- [x] **9.4** `test_filterByDateRange_ReturnsTripsInRange`
(+ bonus: `test_filterByDateRange_IncludesOverlappingTrips`)
- [x] **9.5** `test_filterByStatus_Planned_ReturnsPlanned`
**TDD Note:** `Trip.status: TripStatus` property added
- [x] **9.6** `test_filterByStatus_InProgress_ReturnsInProgress`
- [x] **9.7** `test_filterByStatus_Completed_ReturnsCompleted`
#### 9B: Combined Filters
- [x] **9.8** `test_combinedFilters_SportAndDate_ReturnsIntersection`
- [x] **9.9** `test_combinedFilters_AllFilters_ReturnsIntersection`
#### 9C: Edge Cases
- [x] **9.10** `test_filter_NoMatches_ReturnsEmptyArray`
- [x] **9.11** `test_filter_AllMatch_ReturnsAll`
- [x] **9.12** `test_filter_EmptyInput_ReturnsEmptyArray`
---
### Phase 10: Concurrency Tests
> Prove current implementation is NOT thread-safe (for future work).
**File:** `ConcurrencyTests.swift`
- [x] **10.1** `test_engine_ConcurrentRequests_CurrentlyUnsafe`
Document existing behavior (may crash/race)
- [x] **10.2** `test_engine_SequentialRequests_Succeeds`
**Future (out of scope for now):**
- [ ] `test_engine_ConcurrentRequests_ThreadSafe` (after refactor)
---
### Phase 11: Edge Case Omnibus
> Catch-all for extreme/unusual inputs.
**File:** `EdgeCaseTests.swift`
#### 11A: Data Edge Cases
- [x] **11.1** `test_nilStadium_HandlesGracefully`
- [x] **11.2** `test_malformedDate_HandlesGracefully`
- [x] **11.3** `test_invalidCoordinates_HandlesGracefully`
Lat > 90, Lon > 180
- [x] **11.4** `test_missingRequiredFields_HandlesGracefully`
#### 11B: Boundary Conditions
- [x] **11.5** `test_exactlyAtDrivingLimit_Succeeds`
- [x] **11.6** `test_oneMileOverLimit_Fails`
- [x] **11.7** `test_exactlyAtRadiusBoundary_IncludesGame`
Game at exactly 50 miles
- [x] **11.8** `test_oneFootOverRadius_ExcludesGame`
#### 11C: Time Zone Cases
- [x] **11.9** `test_gameInDifferentTimeZone_NormalizesToUTC`
- [x] **11.10** `test_dstSpringForward_HandlesCorrectly`
- [x] **11.11** `test_dstFallBack_HandlesCorrectly`
---
## API Proposals (TDD Discoveries)
These APIs will be defined by tests and need implementation:
| Property/Type | Defined In | Description |
|--------------|------------|-------------|
| `Trip.warnings: [PlanningWarning]` | 2.13 | Warnings when planner overrides preferences |
| `PlanningWarning` enum | 2.13 | `.repeatCityOverridden(city: String)`, etc. |
| `PlanningError.noGamesFound` | 4.4 | Thrown when date range has no games |
| `Trip.status: TripStatus` | 9.5 | `.planned`, `.inProgress`, `.completed` |
---
## Execution Order
**Recommended batch order for TDD cycle:**
1. **Phase 0** — Infrastructure (no app changes)
2. **Phase 1** — TravelEstimator (foundation, likely passes)
3. **Phase 2** — GameDAGRouter core (highest risk, most edge cases)
4. **Phase 3** — GameDAGRouter scale (set baselines)
5. **Phase 4-6** — Scenario planners (A, B, C)
6. **Phase 7** — TripPlanningEngine integration
7. **Phase 8** — ItineraryBuilder
8. **Phase 9** — RouteFilters
9. **Phase 10** — Concurrency (documentation tests)
10. **Phase 11** — Edge cases
---
## Progress Tracking
| Phase | Tests | Passing | Status |
|-------|-------|---------|--------|
| 0 | 7 | 7 | Complete |
| 1 | 10 | 10 | Complete |
| 2 | 26 | 26 | Complete |
| 3 | 10 | 10 | Complete |
| 4 | 9 | 9 | Complete |
| 5 | 8 | 8 | Complete |
| 6 | 10 | 10 | Complete |
| 7 | 12 | 12 | Complete |
| 8 | 6 | 6 | Complete |
| 9 | 13 | 13 | Complete |
| 10 | 2 | 2 | Complete |
| 11 | 11 | 11 | Complete |
| **Total** | **124** | **124** | |
---
## Notes
- **Hang timeout:** 30 seconds (tests marked as hanging if exceeded)
- **Performance timeout:** 5 minutes per test (scale tests)
- **Nearby radius:** 50 miles driving distance (`TestConstants.nearbyRadiusMiles`)
- **Brute force threshold:** ≤8 stops for exact optimality verification
- **Baselines:** Will be recorded after initial runs, then hardcoded
---
*Last updated: 2026-01-11 (Phase 11 complete — All tests passing)*