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:
264
SportsTimeTests/Mocks/MockAppDataProvider.swift
Normal file
264
SportsTimeTests/Mocks/MockAppDataProvider.swift
Normal 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 }
|
||||
}
|
||||
294
SportsTimeTests/Mocks/MockCloudKitService.swift
Normal file
294
SportsTimeTests/Mocks/MockCloudKitService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
296
SportsTimeTests/Mocks/MockLocationService.swift
Normal file
296
SportsTimeTests/Mocks/MockLocationService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user