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

View File

@@ -0,0 +1,264 @@
//
// MockAppDataProvider.swift
// SportsTimeTests
//
// Mock implementation of AppDataProvider for testing without SwiftData dependencies.
//
import Foundation
import Combine
@testable import SportsTime
// MARK: - Mock App Data Provider
@MainActor
final class MockAppDataProvider: ObservableObject {
// MARK: - Published State
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
// MARK: - Internal Storage
private var teamsById: [UUID: Team] = [:]
private var stadiumsById: [UUID: Stadium] = [:]
private var games: [Game] = []
private var gamesById: [UUID: Game] = [:]
// MARK: - Configuration
struct Configuration {
var simulatedLatency: TimeInterval = 0
var shouldFailOnLoad: Bool = false
var shouldFailOnFetch: Bool = false
var isEmpty: Bool = false
static var `default`: Configuration { Configuration() }
static var empty: Configuration { Configuration(isEmpty: true) }
static var failing: Configuration { Configuration(shouldFailOnLoad: true) }
static var slow: Configuration { Configuration(simulatedLatency: 1.0) }
}
private var config: Configuration
// MARK: - Call Tracking
private(set) var loadInitialDataCallCount = 0
private(set) var fetchGamesCallCount = 0
private(set) var fetchRichGamesCallCount = 0
// MARK: - Initialization
init(config: Configuration = .default) {
self.config = config
}
// MARK: - Configuration Methods
func configure(_ newConfig: Configuration) {
self.config = newConfig
}
func setTeams(_ newTeams: [Team]) {
self.teams = newTeams
self.teamsById = Dictionary(uniqueKeysWithValues: newTeams.map { ($0.id, $0) })
}
func setStadiums(_ newStadiums: [Stadium]) {
self.stadiums = newStadiums
self.stadiumsById = Dictionary(uniqueKeysWithValues: newStadiums.map { ($0.id, $0) })
}
func setGames(_ newGames: [Game]) {
self.games = newGames
self.gamesById = Dictionary(uniqueKeysWithValues: newGames.map { ($0.id, $0) })
}
func reset() {
teams = []
stadiums = []
games = []
teamsById = [:]
stadiumsById = [:]
gamesById = [:]
isLoading = false
error = nil
errorMessage = nil
loadInitialDataCallCount = 0
fetchGamesCallCount = 0
fetchRichGamesCallCount = 0
config = .default
}
// MARK: - Simulated Network
private func simulateLatency() async {
if config.simulatedLatency > 0 {
try? await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000))
}
}
// MARK: - Data Loading
func loadInitialData() async {
loadInitialDataCallCount += 1
if config.isEmpty {
teams = []
stadiums = []
return
}
isLoading = true
error = nil
errorMessage = nil
await simulateLatency()
if config.shouldFailOnLoad {
error = DataProviderError.contextNotConfigured
errorMessage = "Mock load failure"
isLoading = false
return
}
isLoading = false
}
func clearError() {
error = nil
errorMessage = nil
}
func retry() async {
await loadInitialData()
}
// MARK: - Data Access
func team(for id: UUID) -> Team? {
teamsById[id]
}
func stadium(for id: UUID) -> Stadium? {
stadiumsById[id]
}
func teams(for sport: Sport) -> [Team] {
teams.filter { $0.sport == sport }
}
// MARK: - Game Fetching
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
fetchGamesCallCount += 1
await simulateLatency()
if config.shouldFailOnFetch {
throw DataProviderError.contextNotConfigured
}
return games.filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: UUID) async throws -> Game? {
await simulateLatency()
if config.shouldFailOnFetch {
throw DataProviderError.contextNotConfigured
}
return gamesById[id]
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
fetchRichGamesCallCount += 1
let filteredGames = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
return filteredGames.compactMap { game in
richGame(from: game)
}
}
func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Convenience Extensions
extension MockAppDataProvider {
/// Load fixture data from FixtureGenerator
func loadFixtures(_ data: FixtureGenerator.GeneratedData) {
setTeams(data.teams)
setStadiums(data.stadiums)
setGames(data.games)
}
/// Create a mock provider with fixture data pre-loaded
static func withFixtures(_ config: FixtureGenerator.Configuration = .default) -> MockAppDataProvider {
let mock = MockAppDataProvider()
let data = FixtureGenerator.generate(with: config)
mock.loadFixtures(data)
return mock
}
/// Create a mock provider configured as empty
static var empty: MockAppDataProvider {
MockAppDataProvider(config: .empty)
}
/// Create a mock provider configured to fail
static var failing: MockAppDataProvider {
MockAppDataProvider(config: .failing)
}
}
// MARK: - Test Helpers
extension MockAppDataProvider {
/// Add a single game
func addGame(_ game: Game) {
games.append(game)
gamesById[game.id] = game
}
/// Add a single team
func addTeam(_ team: Team) {
teams.append(team)
teamsById[team.id] = team
}
/// Add a single stadium
func addStadium(_ stadium: Stadium) {
stadiums.append(stadium)
stadiumsById[stadium.id] = stadium
}
/// Get all games (for test verification)
func allGames() -> [Game] {
games
}
/// Get games count
var gamesCount: Int { games.count }
/// Get teams count
var teamsCount: Int { teams.count }
/// Get stadiums count
var stadiumsCount: Int { stadiums.count }
}

View File

@@ -0,0 +1,294 @@
//
// MockCloudKitService.swift
// SportsTimeTests
//
// Mock implementation of CloudKitService for testing without network dependencies.
//
import Foundation
@testable import SportsTime
// MARK: - Mock CloudKit Service
actor MockCloudKitService {
// MARK: - Configuration
struct Configuration {
var isAvailable: Bool = true
var simulatedLatency: TimeInterval = 0
var shouldFailWithError: CloudKitError? = nil
var errorAfterNCalls: Int? = nil
static var `default`: Configuration { Configuration() }
static var offline: Configuration { Configuration(isAvailable: false) }
static var slow: Configuration { Configuration(simulatedLatency: 2.0) }
}
// MARK: - Stored Data
private var stadiums: [Stadium] = []
private var teams: [Team] = []
private var games: [Game] = []
private var leagueStructure: [LeagueStructureModel] = []
private var teamAliases: [TeamAlias] = []
private var stadiumAliases: [StadiumAlias] = []
// MARK: - Call Tracking
private(set) var fetchStadiumsCallCount = 0
private(set) var fetchTeamsCallCount = 0
private(set) var fetchGamesCallCount = 0
private(set) var isAvailableCallCount = 0
// MARK: - Configuration
private var config: Configuration
// MARK: - Initialization
init(config: Configuration = .default) {
self.config = config
}
// MARK: - Configuration Methods
func configure(_ newConfig: Configuration) {
self.config = newConfig
}
func setStadiums(_ stadiums: [Stadium]) {
self.stadiums = stadiums
}
func setTeams(_ teams: [Team]) {
self.teams = teams
}
func setGames(_ games: [Game]) {
self.games = games
}
func setLeagueStructure(_ structure: [LeagueStructureModel]) {
self.leagueStructure = structure
}
func reset() {
stadiums = []
teams = []
games = []
leagueStructure = []
teamAliases = []
stadiumAliases = []
fetchStadiumsCallCount = 0
fetchTeamsCallCount = 0
fetchGamesCallCount = 0
isAvailableCallCount = 0
config = .default
}
// MARK: - Simulated Network
private func simulateNetwork() async throws {
// Simulate latency
if config.simulatedLatency > 0 {
try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000))
}
// Check for configured error
if let error = config.shouldFailWithError {
throw error
}
}
private func checkErrorAfterNCalls(_ callCount: Int) throws {
if let errorAfterN = config.errorAfterNCalls, callCount >= errorAfterN {
throw config.shouldFailWithError ?? CloudKitError.networkUnavailable
}
}
// MARK: - Availability
func isAvailable() async -> Bool {
isAvailableCallCount += 1
return config.isAvailable
}
func checkAvailabilityWithError() async throws {
if !config.isAvailable {
throw CloudKitError.networkUnavailable
}
if let error = config.shouldFailWithError {
throw error
}
}
// MARK: - Fetch Operations
func fetchStadiums() async throws -> [Stadium] {
fetchStadiumsCallCount += 1
try checkErrorAfterNCalls(fetchStadiumsCallCount)
try await simulateNetwork()
return stadiums
}
func fetchTeams(for sport: Sport) async throws -> [Team] {
fetchTeamsCallCount += 1
try checkErrorAfterNCalls(fetchTeamsCallCount)
try await simulateNetwork()
return teams.filter { $0.sport == sport }
}
func fetchGames(
sports: Set<Sport>,
startDate: Date,
endDate: Date
) async throws -> [Game] {
fetchGamesCallCount += 1
try checkErrorAfterNCalls(fetchGamesCallCount)
try await simulateNetwork()
return games.filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: UUID) async throws -> Game? {
try await simulateNetwork()
return games.first { $0.id == id }
}
// MARK: - Sync Fetch Methods
func fetchStadiumsForSync() async throws -> [CloudKitService.SyncStadium] {
try await simulateNetwork()
return stadiums.map { stadium in
CloudKitService.SyncStadium(
stadium: stadium,
canonicalId: stadium.id.uuidString
)
}
}
func fetchTeamsForSync(for sport: Sport) async throws -> [CloudKitService.SyncTeam] {
try await simulateNetwork()
return teams.filter { $0.sport == sport }.map { team in
CloudKitService.SyncTeam(
team: team,
canonicalId: team.id.uuidString,
stadiumCanonicalId: team.stadiumId.uuidString
)
}
}
func fetchGamesForSync(
sports: Set<Sport>,
startDate: Date,
endDate: Date
) async throws -> [CloudKitService.SyncGame] {
try await simulateNetwork()
return games.filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}.map { game in
CloudKitService.SyncGame(
game: game,
canonicalId: game.id.uuidString,
homeTeamCanonicalId: game.homeTeamId.uuidString,
awayTeamCanonicalId: game.awayTeamId.uuidString,
stadiumCanonicalId: game.stadiumId.uuidString
)
}
}
// MARK: - League Structure & Aliases
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {
try await simulateNetwork()
if let sport = sport {
return leagueStructure.filter { $0.sport == sport.rawValue }
}
return leagueStructure
}
func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] {
try await simulateNetwork()
if let teamId = teamCanonicalId {
return teamAliases.filter { $0.teamCanonicalId == teamId }
}
return teamAliases
}
func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] {
try await simulateNetwork()
if let stadiumId = stadiumCanonicalId {
return stadiumAliases.filter { $0.stadiumCanonicalId == stadiumId }
}
return stadiumAliases
}
// MARK: - Delta Sync
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
try await simulateNetwork()
guard let lastSync = lastSync else {
return leagueStructure
}
return leagueStructure.filter { $0.lastModified > lastSync }
}
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
try await simulateNetwork()
guard let lastSync = lastSync else {
return teamAliases
}
return teamAliases.filter { $0.lastModified > lastSync }
}
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
try await simulateNetwork()
guard let lastSync = lastSync else {
return stadiumAliases
}
return stadiumAliases.filter { $0.lastModified > lastSync }
}
// MARK: - Subscriptions (No-ops for testing)
func subscribeToScheduleUpdates() async throws {}
func subscribeToLeagueStructureUpdates() async throws {}
func subscribeToTeamAliasUpdates() async throws {}
func subscribeToStadiumAliasUpdates() async throws {}
func subscribeToAllUpdates() async throws {}
}
// MARK: - Convenience Extensions
extension MockCloudKitService {
/// Load fixture data from FixtureGenerator
func loadFixtures(_ data: FixtureGenerator.GeneratedData) {
Task {
await setStadiums(data.stadiums)
await setTeams(data.teams)
await setGames(data.games)
}
}
/// Configure to simulate specific error scenarios
static func withError(_ error: CloudKitError) -> MockCloudKitService {
let mock = MockCloudKitService()
Task {
await mock.configure(Configuration(shouldFailWithError: error))
}
return mock
}
/// Configure to be offline
static var offline: MockCloudKitService {
MockCloudKitService(config: .offline)
}
}

View File

@@ -0,0 +1,296 @@
//
// MockLocationService.swift
// SportsTimeTests
//
// Mock implementation of LocationService for testing without MapKit dependencies.
//
import Foundation
import CoreLocation
import MapKit
@testable import SportsTime
// MARK: - Mock Location Service
actor MockLocationService {
// MARK: - Configuration
struct Configuration {
var simulatedLatency: TimeInterval = 0
var shouldFailGeocode: Bool = false
var shouldFailRoute: Bool = false
var defaultDrivingSpeedMPH: Double = 60.0
var useHaversineForDistance: Bool = true
static var `default`: Configuration { Configuration() }
static var slow: Configuration { Configuration(simulatedLatency: 1.0) }
static var failingGeocode: Configuration { Configuration(shouldFailGeocode: true) }
static var failingRoute: Configuration { Configuration(shouldFailRoute: true) }
}
// MARK: - Pre-configured Responses
private var geocodeResponses: [String: CLLocationCoordinate2D] = [:]
private var routeResponses: [String: RouteInfo] = [:]
// MARK: - Call Tracking
private(set) var geocodeCallCount = 0
private(set) var reverseGeocodeCallCount = 0
private(set) var calculateRouteCallCount = 0
private(set) var searchLocationsCallCount = 0
// MARK: - Configuration
private var config: Configuration
// MARK: - Initialization
init(config: Configuration = .default) {
self.config = config
}
// MARK: - Configuration Methods
func configure(_ newConfig: Configuration) {
self.config = newConfig
}
func setGeocodeResponse(for address: String, coordinate: CLLocationCoordinate2D) {
geocodeResponses[address.lowercased()] = coordinate
}
func setRouteResponse(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, route: RouteInfo) {
let key = routeKey(from: from, to: to)
routeResponses[key] = route
}
func reset() {
geocodeResponses = [:]
routeResponses = [:]
geocodeCallCount = 0
reverseGeocodeCallCount = 0
calculateRouteCallCount = 0
searchLocationsCallCount = 0
config = .default
}
// MARK: - Simulated Network
private func simulateNetwork() async throws {
if config.simulatedLatency > 0 {
try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000))
}
}
// MARK: - Geocoding
func geocode(_ address: String) async throws -> CLLocationCoordinate2D? {
geocodeCallCount += 1
try await simulateNetwork()
if config.shouldFailGeocode {
throw LocationError.geocodingFailed
}
// Check pre-configured responses
if let coordinate = geocodeResponses[address.lowercased()] {
return coordinate
}
// Return nil for unknown addresses (simulating "not found")
return nil
}
func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> String? {
reverseGeocodeCallCount += 1
try await simulateNetwork()
if config.shouldFailGeocode {
throw LocationError.geocodingFailed
}
// Return a simple formatted string based on coordinates
return "Location at \(String(format: "%.2f", coordinate.latitude)), \(String(format: "%.2f", coordinate.longitude))"
}
func resolveLocation(_ input: LocationInput) async throws -> LocationInput {
if input.isResolved { return input }
let searchText = input.address ?? input.name
guard let coordinate = try await geocode(searchText) else {
throw LocationError.geocodingFailed
}
return LocationInput(
name: input.name,
coordinate: coordinate,
address: input.address
)
}
// MARK: - Location Search
func searchLocations(_ query: String) async throws -> [LocationSearchResult] {
searchLocationsCallCount += 1
try await simulateNetwork()
if config.shouldFailGeocode {
return []
}
// Check if we have a pre-configured response for this query
if let coordinate = geocodeResponses[query.lowercased()] {
return [
LocationSearchResult(
name: query,
address: "Mocked Address",
coordinate: coordinate
)
]
}
return []
}
// MARK: - Distance Calculations
func calculateDistance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> CLLocationDistance {
if config.useHaversineForDistance {
return haversineDistance(from: from, to: to)
}
// Simple Euclidean approximation (less accurate but faster)
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLocation.distance(from: toLocation)
}
func calculateDrivingRoute(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) async throws -> RouteInfo {
calculateRouteCallCount += 1
try await simulateNetwork()
if config.shouldFailRoute {
throw LocationError.routeNotFound
}
// Check pre-configured routes
let key = routeKey(from: from, to: to)
if let route = routeResponses[key] {
return route
}
// Generate estimated route based on haversine distance
let distanceMeters = haversineDistance(from: from, to: to)
let distanceMiles = distanceMeters * 0.000621371
// Estimate driving time (add 20% for real-world conditions)
let drivingHours = (distanceMiles / config.defaultDrivingSpeedMPH) * 1.2
let travelTimeSeconds = drivingHours * 3600
return RouteInfo(
distance: distanceMeters,
expectedTravelTime: travelTimeSeconds,
polyline: nil
)
}
func calculateDrivingMatrix(
origins: [CLLocationCoordinate2D],
destinations: [CLLocationCoordinate2D]
) async throws -> [[RouteInfo?]] {
var matrix: [[RouteInfo?]] = []
for origin in origins {
var row: [RouteInfo?] = []
for destination in destinations {
do {
let route = try await calculateDrivingRoute(from: origin, to: destination)
row.append(route)
} catch {
row.append(nil)
}
}
matrix.append(row)
}
return matrix
}
// MARK: - Haversine Distance
/// Calculate haversine distance between two coordinates in meters
private func haversineDistance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> CLLocationDistance {
let earthRadiusMeters: Double = 6371000.0
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let deltaLat = (to.latitude - from.latitude) * .pi / 180
let deltaLon = (to.longitude - from.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMeters * c
}
// MARK: - Helpers
private func routeKey(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> String {
"\(from.latitude),\(from.longitude)->\(to.latitude),\(to.longitude)"
}
}
// MARK: - Convenience Extensions
extension MockLocationService {
/// Pre-configure common city geocoding responses
func loadCommonCities() async {
await setGeocodeResponse(for: "New York, NY", coordinate: FixtureGenerator.KnownLocations.nyc)
await setGeocodeResponse(for: "Los Angeles, CA", coordinate: FixtureGenerator.KnownLocations.la)
await setGeocodeResponse(for: "Chicago, IL", coordinate: FixtureGenerator.KnownLocations.chicago)
await setGeocodeResponse(for: "Boston, MA", coordinate: FixtureGenerator.KnownLocations.boston)
await setGeocodeResponse(for: "Miami, FL", coordinate: FixtureGenerator.KnownLocations.miami)
await setGeocodeResponse(for: "Seattle, WA", coordinate: FixtureGenerator.KnownLocations.seattle)
await setGeocodeResponse(for: "Denver, CO", coordinate: FixtureGenerator.KnownLocations.denver)
}
/// Create a mock service with common cities pre-loaded
static func withCommonCities() async -> MockLocationService {
let mock = MockLocationService()
await mock.loadCommonCities()
return mock
}
}
// MARK: - Test Helpers
extension MockLocationService {
/// Calculate expected travel time in hours for a given distance
func expectedTravelHours(distanceMiles: Double) -> Double {
(distanceMiles / config.defaultDrivingSpeedMPH) * 1.2
}
/// Check if a coordinate is within radius of another
func isWithinRadius(
_ coordinate: CLLocationCoordinate2D,
of center: CLLocationCoordinate2D,
radiusMiles: Double
) -> Bool {
let distanceMeters = haversineDistance(from: center, to: coordinate)
let distanceMiles = distanceMeters * 0.000621371
return distanceMiles <= radiusMiles
}
}