Files
Sportstime/docs/plans/2026-01-16-test-rewrite-design.md
Trey t 966a580def 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 <noreply@anthropic.com>
2026-01-16 10:22:25 -06:00

11 KiB

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)

{
  "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

# 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:

@Suite(.serialized)
struct DatabaseTests {
    // These tests run one at a time
}

Test Patterns

Standard Test Structure

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

// 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

// 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