refactor(tests): TDD rewrite of all unit tests with spec documentation
Complete rewrite of unit test suite using TDD methodology: Planning Engine Tests: - GameDAGRouterTests: Beam search, anchor games, transitions - ItineraryBuilderTests: Stop connection, validators, EV enrichment - RouteFiltersTests: Region, time window, scoring filters - ScenarioA/B/C/D PlannerTests: All planning scenarios - TravelEstimatorTests: Distance, duration, travel days - TripPlanningEngineTests: Orchestration, caching, preferences Domain Model Tests: - AchievementDefinitionsTests, AnySportTests, DivisionTests - GameTests, ProgressTests, RegionTests, StadiumTests - TeamTests, TravelSegmentTests, TripTests, TripPollTests - TripPreferencesTests, TripStopTests, SportTests Service Tests: - FreeScoreAPITests, RouteDescriptionGeneratorTests - SuggestedTripsGeneratorTests Export Tests: - ShareableContentTests (card types, themes, dimensions) Bug fixes discovered through TDD: - ShareCardDimensions: mapSnapshotSize exceeded available width (960x480) - ScenarioBPlanner: Added anchor game validation filter All tests include: - Specification tests (expected behavior) - Invariant tests (properties that must always hold) - Edge case tests (boundary conditions) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,305 +0,0 @@
|
||||
//
|
||||
// BruteForceRouteVerifier.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Exhaustively enumerates all route permutations to verify optimality.
|
||||
// Used for inputs with ≤8 stops where brute force is feasible.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
// MARK: - Route Verifier
|
||||
|
||||
struct BruteForceRouteVerifier {
|
||||
|
||||
// MARK: - Route Comparison Result
|
||||
|
||||
struct VerificationResult {
|
||||
let isOptimal: Bool
|
||||
let proposedRouteDistance: Double
|
||||
let optimalRouteDistance: Double
|
||||
let optimalRoute: [String]?
|
||||
let improvement: Double? // Percentage improvement if not optimal
|
||||
let permutationsChecked: Int
|
||||
|
||||
var improvementPercentage: Double? {
|
||||
guard let improvement = improvement else { return nil }
|
||||
return improvement * 100
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
/// Verify that a proposed route is optimal (or near-optimal) by checking all permutations
|
||||
/// - Parameters:
|
||||
/// - proposedRoute: The route to verify (ordered list of stop IDs)
|
||||
/// - stops: Dictionary mapping stop IDs to their coordinates
|
||||
/// - tolerance: Percentage tolerance for "near-optimal" (default 0 = must be exactly optimal)
|
||||
/// - Returns: Verification result
|
||||
static func verify(
|
||||
proposedRoute: [String],
|
||||
stops: [String: CLLocationCoordinate2D],
|
||||
tolerance: Double = 0
|
||||
) -> VerificationResult {
|
||||
guard proposedRoute.count <= TestConstants.bruteForceMaxStops else {
|
||||
fatalError("BruteForceRouteVerifier should only be used for ≤\(TestConstants.bruteForceMaxStops) stops")
|
||||
}
|
||||
|
||||
guard proposedRoute.count >= 2 else {
|
||||
// Single stop or empty - trivially optimal
|
||||
return VerificationResult(
|
||||
isOptimal: true,
|
||||
proposedRouteDistance: 0,
|
||||
optimalRouteDistance: 0,
|
||||
optimalRoute: proposedRoute,
|
||||
improvement: nil,
|
||||
permutationsChecked: 1
|
||||
)
|
||||
}
|
||||
|
||||
let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops)
|
||||
|
||||
// Find optimal route by checking all permutations
|
||||
let allPermutations = permutations(of: proposedRoute)
|
||||
var optimalDistance = Double.infinity
|
||||
var optimalRoute: [String] = []
|
||||
|
||||
for permutation in allPermutations {
|
||||
let distance = calculateRouteDistance(permutation, stops: stops)
|
||||
if distance < optimalDistance {
|
||||
optimalDistance = distance
|
||||
optimalRoute = permutation
|
||||
}
|
||||
}
|
||||
|
||||
let isOptimal: Bool
|
||||
var improvement: Double? = nil
|
||||
|
||||
if tolerance == 0 {
|
||||
// Exact optimality check with floating point tolerance
|
||||
isOptimal = abs(proposedDistance - optimalDistance) < 0.001
|
||||
} else {
|
||||
// Within tolerance
|
||||
let maxAllowed = optimalDistance * (1 + tolerance)
|
||||
isOptimal = proposedDistance <= maxAllowed
|
||||
}
|
||||
|
||||
if !isOptimal && optimalDistance > 0 {
|
||||
improvement = (proposedDistance - optimalDistance) / optimalDistance
|
||||
}
|
||||
|
||||
return VerificationResult(
|
||||
isOptimal: isOptimal,
|
||||
proposedRouteDistance: proposedDistance,
|
||||
optimalRouteDistance: optimalDistance,
|
||||
optimalRoute: optimalRoute,
|
||||
improvement: improvement,
|
||||
permutationsChecked: allPermutations.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify a route is optimal with a fixed start and end point
|
||||
static func verifyWithFixedEndpoints(
|
||||
proposedRoute: [String],
|
||||
stops: [String: CLLocationCoordinate2D],
|
||||
startId: String,
|
||||
endId: String,
|
||||
tolerance: Double = 0
|
||||
) -> VerificationResult {
|
||||
guard proposedRoute.first == startId && proposedRoute.last == endId else {
|
||||
// Invalid route - doesn't match required endpoints
|
||||
return VerificationResult(
|
||||
isOptimal: false,
|
||||
proposedRouteDistance: Double.infinity,
|
||||
optimalRouteDistance: 0,
|
||||
optimalRoute: nil,
|
||||
improvement: nil,
|
||||
permutationsChecked: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Get intermediate stops (excluding start and end)
|
||||
let intermediateStops = proposedRoute.dropFirst().dropLast()
|
||||
|
||||
guard intermediateStops.count <= TestConstants.bruteForceMaxStops - 2 else {
|
||||
fatalError("BruteForceRouteVerifier: too many intermediate stops")
|
||||
}
|
||||
|
||||
let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops)
|
||||
|
||||
// Generate all permutations of intermediate stops
|
||||
let allPermutations = permutations(of: Array(intermediateStops))
|
||||
var optimalDistance = Double.infinity
|
||||
var optimalRoute: [String] = []
|
||||
|
||||
for permutation in allPermutations {
|
||||
var fullRoute = [startId]
|
||||
fullRoute.append(contentsOf: permutation)
|
||||
fullRoute.append(endId)
|
||||
|
||||
let distance = calculateRouteDistance(fullRoute, stops: stops)
|
||||
if distance < optimalDistance {
|
||||
optimalDistance = distance
|
||||
optimalRoute = fullRoute
|
||||
}
|
||||
}
|
||||
|
||||
let isOptimal: Bool
|
||||
var improvement: Double? = nil
|
||||
|
||||
if tolerance == 0 {
|
||||
isOptimal = abs(proposedDistance - optimalDistance) < 0.001
|
||||
} else {
|
||||
let maxAllowed = optimalDistance * (1 + tolerance)
|
||||
isOptimal = proposedDistance <= maxAllowed
|
||||
}
|
||||
|
||||
if !isOptimal && optimalDistance > 0 {
|
||||
improvement = (proposedDistance - optimalDistance) / optimalDistance
|
||||
}
|
||||
|
||||
return VerificationResult(
|
||||
isOptimal: isOptimal,
|
||||
proposedRouteDistance: proposedDistance,
|
||||
optimalRouteDistance: optimalDistance,
|
||||
optimalRoute: optimalRoute,
|
||||
improvement: improvement,
|
||||
permutationsChecked: allPermutations.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if there's an obviously better route (significantly shorter)
|
||||
static func hasObviouslyBetterRoute(
|
||||
proposedRoute: [String],
|
||||
stops: [String: CLLocationCoordinate2D],
|
||||
threshold: Double = 0.1 // 10% improvement threshold
|
||||
) -> (hasBetter: Bool, improvement: Double?) {
|
||||
let result = verify(proposedRoute: proposedRoute, stops: stops, tolerance: threshold)
|
||||
return (!result.isOptimal, result.improvement)
|
||||
}
|
||||
|
||||
// MARK: - Distance Calculation
|
||||
|
||||
/// Calculate total route distance using haversine formula
|
||||
static func calculateRouteDistance(
|
||||
_ route: [String],
|
||||
stops: [String: CLLocationCoordinate2D]
|
||||
) -> Double {
|
||||
guard route.count >= 2 else { return 0 }
|
||||
|
||||
var totalDistance: Double = 0
|
||||
|
||||
for i in 0..<(route.count - 1) {
|
||||
guard let from = stops[route[i]],
|
||||
let to = stops[route[i + 1]] else {
|
||||
continue
|
||||
}
|
||||
totalDistance += haversineDistanceMiles(from: from, to: to)
|
||||
}
|
||||
|
||||
return totalDistance
|
||||
}
|
||||
|
||||
/// Haversine distance between two coordinates in miles
|
||||
static func haversineDistanceMiles(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let earthRadiusMiles = TestConstants.earthRadiusMiles
|
||||
|
||||
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 earthRadiusMiles * c
|
||||
}
|
||||
|
||||
// MARK: - Permutation Generation
|
||||
|
||||
/// Generate all permutations of an array (Heap's algorithm)
|
||||
static func permutations<T>(of array: [T]) -> [[T]] {
|
||||
var result: [[T]] = []
|
||||
var arr = array
|
||||
|
||||
func generate(_ n: Int) {
|
||||
if n == 1 {
|
||||
result.append(arr)
|
||||
return
|
||||
}
|
||||
|
||||
for i in 0..<n {
|
||||
generate(n - 1)
|
||||
if n % 2 == 0 {
|
||||
arr.swapAt(i, n - 1)
|
||||
} else {
|
||||
arr.swapAt(0, n - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generate(array.count)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Factorial
|
||||
|
||||
/// Calculate factorial (for estimating permutation count)
|
||||
static func factorial(_ n: Int) -> Int {
|
||||
guard n > 1 else { return 1 }
|
||||
return (1...n).reduce(1, *)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension BruteForceRouteVerifier {
|
||||
/// Verify a trip's route is optimal
|
||||
static func verifyTrip(_ trip: Trip) -> VerificationResult {
|
||||
var stops: [String: CLLocationCoordinate2D] = [:]
|
||||
|
||||
for stop in trip.stops {
|
||||
if let coord = stop.coordinate {
|
||||
stops[stop.id.uuidString] = coord
|
||||
}
|
||||
}
|
||||
|
||||
let routeIds = trip.stops.map { $0.id.uuidString }
|
||||
return verify(proposedRoute: routeIds, stops: stops)
|
||||
}
|
||||
|
||||
/// Verify a list of stadiums forms an optimal route
|
||||
static func verifyStadiumRoute(_ stadiums: [Stadium]) -> VerificationResult {
|
||||
let stops = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0.coordinate) })
|
||||
let routeIds = stadiums.map { $0.id }
|
||||
return verify(proposedRoute: routeIds, stops: stops)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Assertions
|
||||
|
||||
extension BruteForceRouteVerifier.VerificationResult {
|
||||
/// Returns a detailed failure message if not optimal
|
||||
var failureMessage: String? {
|
||||
guard !isOptimal else { return nil }
|
||||
|
||||
var message = "Route is not optimal. "
|
||||
message += "Proposed: \(String(format: "%.1f", proposedRouteDistance)) miles, "
|
||||
message += "Optimal: \(String(format: "%.1f", optimalRouteDistance)) miles"
|
||||
|
||||
if let improvement = improvementPercentage {
|
||||
message += " (\(String(format: "%.1f", improvement))% longer)"
|
||||
}
|
||||
|
||||
message += ". Checked \(permutationsChecked) permutations."
|
||||
|
||||
return message
|
||||
}
|
||||
}
|
||||
102
SportsTimeTests/Helpers/MockServices.swift
Normal file
102
SportsTimeTests/Helpers/MockServices.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// MockServices.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Mock implementations of services for testing. These mocks allow tests
|
||||
// to control service behavior and verify interactions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
// MARK: - Mock Data Provider
|
||||
|
||||
/// Mock data provider for testing components that depend on game/stadium/team data.
|
||||
@MainActor
|
||||
final class MockDataProvider {
|
||||
var games: [Game] = []
|
||||
var stadiums: [String: Stadium] = [:]
|
||||
var teams: [String: Team] = [:]
|
||||
|
||||
var shouldFail = false
|
||||
var failureError: Error = NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock failure"])
|
||||
|
||||
func configure(games: [Game], stadiums: [Stadium], teams: [Team]) {
|
||||
self.games = games
|
||||
self.stadiums = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
|
||||
self.teams = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
|
||||
}
|
||||
|
||||
func stadium(for id: String) -> Stadium? {
|
||||
stadiums[id]
|
||||
}
|
||||
|
||||
func team(for id: String) -> Team? {
|
||||
teams[id]
|
||||
}
|
||||
|
||||
func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) throws -> [Game] {
|
||||
if shouldFail { throw failureError }
|
||||
return games.filter { game in
|
||||
sports.contains(game.sport) &&
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Location Service
|
||||
|
||||
/// Mock location service for testing distance and travel time calculations.
|
||||
@MainActor
|
||||
final class MockLocationService {
|
||||
var stubbedDistances: [String: Double] = [:] // "from_to" -> meters
|
||||
var stubbedTravelTimes: [String: TimeInterval] = [:] // "from_to" -> seconds
|
||||
var defaultDistanceMeters: Double = 100_000 // ~62 miles
|
||||
var defaultTravelTimeSeconds: TimeInterval = 3600 // 1 hour
|
||||
|
||||
var shouldFail = false
|
||||
var failureError: Error = NSError(domain: "LocationError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Location unavailable"])
|
||||
|
||||
var calculateDistanceCalls: [(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D)] = []
|
||||
var calculateTravelTimeCalls: [(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D)] = []
|
||||
|
||||
func stubDistance(from: String, to: String, meters: Double) {
|
||||
stubbedDistances["\(from)_\(to)"] = meters
|
||||
}
|
||||
|
||||
func stubTravelTime(from: String, to: String, seconds: TimeInterval) {
|
||||
stubbedTravelTimes["\(from)_\(to)"] = seconds
|
||||
}
|
||||
|
||||
func calculateDistance(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> Double {
|
||||
calculateDistanceCalls.append((from: from, to: to))
|
||||
if shouldFail { throw failureError }
|
||||
return defaultDistanceMeters
|
||||
}
|
||||
|
||||
func calculateTravelTime(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> TimeInterval {
|
||||
calculateTravelTimeCalls.append((from: from, to: to))
|
||||
if shouldFail { throw failureError }
|
||||
return defaultTravelTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Route Service
|
||||
|
||||
/// Mock route service for testing route optimization.
|
||||
@MainActor
|
||||
final class MockRouteService {
|
||||
var shouldFail = false
|
||||
var failureError: Error = NSError(domain: "RouteError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Route unavailable"])
|
||||
|
||||
var optimizeRouteCalls: [[CLLocationCoordinate2D]] = []
|
||||
var stubbedRoute: [Int]? // Indices in order
|
||||
|
||||
func optimizeRoute(waypoints: [CLLocationCoordinate2D]) async throws -> [Int] {
|
||||
optimizeRouteCalls.append(waypoints)
|
||||
if shouldFail { throw failureError }
|
||||
return stubbedRoute ?? Array(0..<waypoints.count)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// TestConstants.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Constants used across test suites for consistent test configuration.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum TestConstants {
|
||||
// MARK: - Distance & Radius
|
||||
|
||||
/// Standard radius for "nearby" game filtering (miles)
|
||||
static let nearbyRadiusMiles: Double = 50.0
|
||||
|
||||
/// Meters per mile conversion
|
||||
static let metersPerMile: Double = 1609.344
|
||||
|
||||
/// Nearby radius in meters
|
||||
static var nearbyRadiusMeters: Double { nearbyRadiusMiles * metersPerMile }
|
||||
|
||||
// MARK: - Timeouts
|
||||
|
||||
/// Maximum time for performance/scale tests (5 minutes)
|
||||
static let performanceTimeout: TimeInterval = 300.0
|
||||
|
||||
/// Maximum time before a test is considered hung (30 seconds)
|
||||
static let hangTimeout: TimeInterval = 30.0
|
||||
|
||||
/// Standard async test timeout
|
||||
static let standardTimeout: TimeInterval = 10.0
|
||||
|
||||
// MARK: - Performance Baselines
|
||||
// These will be recorded after initial runs and updated
|
||||
|
||||
/// Baseline time for 500 games (to be determined)
|
||||
static var baseline500Games: TimeInterval = 0
|
||||
|
||||
/// Baseline time for 2000 games (to be determined)
|
||||
static var baseline2000Games: TimeInterval = 0
|
||||
|
||||
/// Baseline time for 10000 games (to be determined)
|
||||
static var baseline10000Games: TimeInterval = 0
|
||||
|
||||
// MARK: - Driving Constraints
|
||||
|
||||
/// Default max driving hours per day (single driver)
|
||||
static let defaultMaxDrivingHoursPerDay: Double = 8.0
|
||||
|
||||
/// Average driving speed (mph) for estimates
|
||||
static let averageDrivingSpeedMPH: Double = 60.0
|
||||
|
||||
/// Max days lookahead for game transitions
|
||||
static let maxDayLookahead: Int = 5
|
||||
|
||||
// MARK: - Brute Force Verification
|
||||
|
||||
/// Maximum number of stops for brute force verification
|
||||
static let bruteForceMaxStops: Int = 8
|
||||
|
||||
// MARK: - Test Data Sizes
|
||||
|
||||
enum DataSize: Int {
|
||||
case tiny = 5
|
||||
case small = 50
|
||||
case medium = 500
|
||||
case large = 2000
|
||||
case stress = 10000
|
||||
case extreme = 50000
|
||||
}
|
||||
|
||||
// MARK: - Geographic Constants
|
||||
|
||||
/// Earth radius in miles (for haversine)
|
||||
static let earthRadiusMiles: Double = 3958.8
|
||||
|
||||
/// Earth circumference in miles
|
||||
static let earthCircumferenceMiles: Double = 24901.0
|
||||
|
||||
// MARK: - Known Distances (for validation)
|
||||
|
||||
/// NYC to LA approximate distance in miles
|
||||
static let nycToLAMiles: Double = 2451.0
|
||||
|
||||
/// Distance tolerance percentage for validation
|
||||
static let distanceTolerancePercent: Double = 0.01 // 1%
|
||||
}
|
||||
467
SportsTimeTests/Helpers/TestFixtures.swift
Normal file
467
SportsTimeTests/Helpers/TestFixtures.swift
Normal file
@@ -0,0 +1,467 @@
|
||||
//
|
||||
// TestFixtures.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Factory methods for creating test data. These fixtures create realistic
|
||||
// domain objects with sensible defaults that can be customized per test.
|
||||
//
|
||||
// Usage:
|
||||
// let game = TestFixtures.game() // Default game
|
||||
// let game = TestFixtures.game(sport: .nba, city: "Boston") // Customized
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
// MARK: - Test Fixtures
|
||||
|
||||
enum TestFixtures {
|
||||
|
||||
// MARK: - Reference Data
|
||||
|
||||
/// Real stadium coordinates for realistic distance calculations
|
||||
static let coordinates: [String: CLLocationCoordinate2D] = [
|
||||
"New York": CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855), // NYC (Midtown)
|
||||
"Boston": CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972), // Fenway Park
|
||||
"Chicago": CLLocationCoordinate2D(latitude: 41.9484, longitude: -87.6553), // Wrigley Field
|
||||
"Los Angeles": CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400), // Dodger Stadium
|
||||
"San Francisco": CLLocationCoordinate2D(latitude: 37.7786, longitude: -122.3893), // Oracle Park
|
||||
"Seattle": CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3325), // T-Mobile Park
|
||||
"Denver": CLLocationCoordinate2D(latitude: 39.7559, longitude: -104.9942), // Coors Field
|
||||
"Houston": CLLocationCoordinate2D(latitude: 29.7573, longitude: -95.3555), // Minute Maid
|
||||
"Miami": CLLocationCoordinate2D(latitude: 25.7781, longitude: -80.2197), // LoanDepot Park
|
||||
"Atlanta": CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006), // Truist Park
|
||||
"Phoenix": CLLocationCoordinate2D(latitude: 33.4455, longitude: -112.0667), // Chase Field
|
||||
"Dallas": CLLocationCoordinate2D(latitude: 32.7473, longitude: -97.0945), // Globe Life Field
|
||||
"Philadelphia": CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665), // Citizens Bank
|
||||
"Detroit": CLLocationCoordinate2D(latitude: 42.3390, longitude: -83.0485), // Comerica Park
|
||||
"Minneapolis": CLLocationCoordinate2D(latitude: 44.9817, longitude: -93.2776), // Target Field
|
||||
]
|
||||
|
||||
/// Time zones for realistic local time testing
|
||||
static let timeZones: [String: String] = [
|
||||
"New York": "America/New_York",
|
||||
"Boston": "America/New_York",
|
||||
"Chicago": "America/Chicago",
|
||||
"Los Angeles": "America/Los_Angeles",
|
||||
"San Francisco": "America/Los_Angeles",
|
||||
"Seattle": "America/Los_Angeles",
|
||||
"Denver": "America/Denver",
|
||||
"Houston": "America/Chicago",
|
||||
"Miami": "America/New_York",
|
||||
"Atlanta": "America/New_York",
|
||||
"Phoenix": "America/Phoenix",
|
||||
"Dallas": "America/Chicago",
|
||||
"Philadelphia": "America/New_York",
|
||||
"Detroit": "America/Detroit",
|
||||
"Minneapolis": "America/Chicago",
|
||||
]
|
||||
|
||||
/// State abbreviations
|
||||
static let states: [String: String] = [
|
||||
"New York": "NY", "Boston": "MA", "Chicago": "IL",
|
||||
"Los Angeles": "CA", "San Francisco": "CA", "Seattle": "WA",
|
||||
"Denver": "CO", "Houston": "TX", "Miami": "FL", "Atlanta": "GA",
|
||||
"Phoenix": "AZ", "Dallas": "TX", "Philadelphia": "PA",
|
||||
"Detroit": "MI", "Minneapolis": "MN",
|
||||
]
|
||||
|
||||
// MARK: - Game Factory
|
||||
|
||||
/// Creates a Game with realistic defaults.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Returns a valid Game with all required fields populated
|
||||
/// - ID follows canonical format: "game_{sport}_{season}_{away}_{home}_{mmdd}"
|
||||
/// - DateTime defaults to noon tomorrow in specified city's timezone
|
||||
static func game(
|
||||
id: String? = nil,
|
||||
sport: Sport = .mlb,
|
||||
city: String = "New York",
|
||||
dateTime: Date? = nil,
|
||||
homeTeamId: String? = nil,
|
||||
awayTeamId: String? = nil,
|
||||
stadiumId: String? = nil,
|
||||
season: String = "2026",
|
||||
isPlayoff: Bool = false
|
||||
) -> Game {
|
||||
let actualDateTime = dateTime ?? Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||
let homeId = homeTeamId ?? "team_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
let awayId = awayTeamId ?? "team_\(sport.rawValue.lowercased())_visitor"
|
||||
let stadId = stadiumId ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMdd"
|
||||
let dateStr = formatter.string(from: actualDateTime)
|
||||
|
||||
let actualId = id ?? "game_\(sport.rawValue.lowercased())_\(season)_\(awayId.split(separator: "_").last ?? "vis")_\(homeId.split(separator: "_").last ?? "home")_\(dateStr)"
|
||||
|
||||
return Game(
|
||||
id: actualId,
|
||||
homeTeamId: homeId,
|
||||
awayTeamId: awayId,
|
||||
stadiumId: stadId,
|
||||
dateTime: actualDateTime,
|
||||
sport: sport,
|
||||
season: season,
|
||||
isPlayoff: isPlayoff
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates multiple games spread across time and cities.
|
||||
///
|
||||
/// - Parameter count: Number of games to create
|
||||
/// - Parameter cities: Cities to distribute games across (cycles through list)
|
||||
/// - Parameter startDate: First game date (subsequent games spread by daySpread)
|
||||
/// - Parameter daySpread: Days between games
|
||||
static func games(
|
||||
count: Int,
|
||||
sport: Sport = .mlb,
|
||||
cities: [String] = ["New York", "Boston", "Chicago", "Los Angeles"],
|
||||
startDate: Date = Date(),
|
||||
daySpread: Int = 1
|
||||
) -> [Game] {
|
||||
(0..<count).map { i in
|
||||
let city = cities[i % cities.count]
|
||||
let gameDate = Calendar.current.date(byAdding: .day, value: i * daySpread, to: startDate)!
|
||||
return game(sport: sport, city: city, dateTime: gameDate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates games for same-day conflict testing.
|
||||
static func sameDayGames(
|
||||
cities: [String],
|
||||
date: Date = Date(),
|
||||
sport: Sport = .mlb
|
||||
) -> [Game] {
|
||||
cities.enumerated().map { index, city in
|
||||
// Stagger times by 3 hours
|
||||
let time = Calendar.current.date(byAdding: .hour, value: 13 + (index * 3), to: Calendar.current.startOfDay(for: date))!
|
||||
return game(sport: sport, city: city, dateTime: time)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Factory
|
||||
|
||||
/// Creates a Stadium with realistic defaults.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Uses real coordinates for known cities
|
||||
/// - ID follows canonical format: "stadium_{sport}_{city}"
|
||||
/// - TimeZone populated for known cities
|
||||
static func stadium(
|
||||
id: String? = nil,
|
||||
name: String? = nil,
|
||||
city: String = "New York",
|
||||
state: String? = nil,
|
||||
sport: Sport = .mlb,
|
||||
capacity: Int = 40000,
|
||||
yearOpened: Int? = nil
|
||||
) -> Stadium {
|
||||
let coordinate = coordinates[city] ?? CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0)
|
||||
let actualState = state ?? states[city] ?? "NY"
|
||||
let actualName = name ?? "\(city) \(sport.rawValue) Stadium"
|
||||
let actualId = id ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
|
||||
return Stadium(
|
||||
id: actualId,
|
||||
name: actualName,
|
||||
city: city,
|
||||
state: actualState,
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
capacity: capacity,
|
||||
sport: sport,
|
||||
yearOpened: yearOpened,
|
||||
timeZoneIdentifier: timeZones[city]
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a stadium map for a set of games.
|
||||
static func stadiumMap(for games: [Game]) -> [String: Stadium] {
|
||||
var map: [String: Stadium] = [:]
|
||||
for game in games {
|
||||
if map[game.stadiumId] == nil {
|
||||
// Extract city from stadium ID (assumes format stadium_sport_city)
|
||||
let parts = game.stadiumId.split(separator: "_")
|
||||
let city = parts.count > 2 ? parts[2...].joined(separator: " ").capitalized : "Unknown"
|
||||
map[game.stadiumId] = stadium(id: game.stadiumId, city: city, sport: game.sport)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// Creates stadiums at specific coordinates for distance testing.
|
||||
static func stadiumsForDistanceTest() -> [Stadium] {
|
||||
[
|
||||
stadium(city: "New York"), // East
|
||||
stadium(city: "Chicago"), // Central
|
||||
stadium(city: "Denver"), // Mountain
|
||||
stadium(city: "Los Angeles"), // West
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Team Factory
|
||||
|
||||
/// Creates a Team with realistic defaults.
|
||||
static func team(
|
||||
id: String? = nil,
|
||||
name: String = "Test Team",
|
||||
abbreviation: String? = nil,
|
||||
sport: Sport = .mlb,
|
||||
city: String = "New York",
|
||||
stadiumId: String? = nil
|
||||
) -> Team {
|
||||
let actualId = id ?? "team_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
let actualAbbr = abbreviation ?? String(city.prefix(3)).uppercased()
|
||||
let actualStadiumId = stadiumId ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
|
||||
return Team(
|
||||
id: actualId,
|
||||
name: name,
|
||||
abbreviation: actualAbbr,
|
||||
sport: sport,
|
||||
city: city,
|
||||
stadiumId: actualStadiumId
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TripStop Factory
|
||||
|
||||
/// Creates a TripStop with realistic defaults.
|
||||
static func tripStop(
|
||||
stopNumber: Int = 1,
|
||||
city: String = "New York",
|
||||
state: String? = nil,
|
||||
arrivalDate: Date? = nil,
|
||||
departureDate: Date? = nil,
|
||||
games: [String] = [],
|
||||
isRestDay: Bool = false
|
||||
) -> TripStop {
|
||||
let coordinate = coordinates[city]
|
||||
let actualState = state ?? states[city] ?? "NY"
|
||||
let arrival = arrivalDate ?? Date()
|
||||
let departure = departureDate ?? Calendar.current.date(byAdding: .day, value: 1, to: arrival)!
|
||||
|
||||
return TripStop(
|
||||
stopNumber: stopNumber,
|
||||
city: city,
|
||||
state: actualState,
|
||||
coordinate: coordinate,
|
||||
arrivalDate: arrival,
|
||||
departureDate: departure,
|
||||
games: games,
|
||||
isRestDay: isRestDay
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a sequence of trip stops for a multi-city trip.
|
||||
static func tripStops(
|
||||
cities: [String],
|
||||
startDate: Date = Date(),
|
||||
daysPerStop: Int = 1
|
||||
) -> [TripStop] {
|
||||
var stops: [TripStop] = []
|
||||
var currentDate = startDate
|
||||
|
||||
for (index, city) in cities.enumerated() {
|
||||
let departure = Calendar.current.date(byAdding: .day, value: daysPerStop, to: currentDate)!
|
||||
stops.append(tripStop(
|
||||
stopNumber: index + 1,
|
||||
city: city,
|
||||
arrivalDate: currentDate,
|
||||
departureDate: departure
|
||||
))
|
||||
currentDate = departure
|
||||
}
|
||||
return stops
|
||||
}
|
||||
|
||||
// MARK: - TravelSegment Factory
|
||||
|
||||
/// Creates a TravelSegment between two cities.
|
||||
static func travelSegment(
|
||||
from: String = "New York",
|
||||
to: String = "Boston",
|
||||
travelMode: TravelMode = .drive
|
||||
) -> TravelSegment {
|
||||
let fromCoord = coordinates[from] ?? CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0)
|
||||
let toCoord = coordinates[to] ?? CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0)
|
||||
|
||||
// Calculate approximate distance (haversine)
|
||||
let distance = haversineDistance(from: fromCoord, to: toCoord)
|
||||
// Estimate driving time at 60 mph average
|
||||
let duration = distance / 60.0 * 3600.0
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: LocationInput(name: from, coordinate: fromCoord),
|
||||
toLocation: LocationInput(name: to, coordinate: toCoord),
|
||||
travelMode: travelMode,
|
||||
distanceMeters: distance * 1609.34, // miles to meters
|
||||
durationSeconds: duration
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TripPreferences Factory
|
||||
|
||||
/// Creates TripPreferences with common defaults.
|
||||
static func preferences(
|
||||
mode: PlanningMode = .dateRange,
|
||||
sports: Set<Sport> = [.mlb],
|
||||
startDate: Date? = nil,
|
||||
endDate: Date? = nil,
|
||||
regions: Set<Region> = [.east, .central, .west],
|
||||
leisureLevel: LeisureLevel = .moderate,
|
||||
travelMode: TravelMode = .drive,
|
||||
needsEVCharging: Bool = false,
|
||||
maxDrivingHoursPerDriver: Double? = nil
|
||||
) -> TripPreferences {
|
||||
let start = startDate ?? Date()
|
||||
let end = endDate ?? Calendar.current.date(byAdding: .day, value: 7, to: start)!
|
||||
|
||||
return TripPreferences(
|
||||
planningMode: mode,
|
||||
sports: sports,
|
||||
travelMode: travelMode,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
leisureLevel: leisureLevel,
|
||||
routePreference: .balanced,
|
||||
needsEVCharging: needsEVCharging,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
selectedRegions: regions
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Trip Factory
|
||||
|
||||
/// Creates a complete Trip with stops and segments.
|
||||
static func trip(
|
||||
name: String = "Test Trip",
|
||||
stops: [TripStop]? = nil,
|
||||
preferences: TripPreferences? = nil,
|
||||
status: TripStatus = .planned
|
||||
) -> Trip {
|
||||
let actualStops = stops ?? tripStops(cities: ["New York", "Boston"])
|
||||
let actualPrefs = preferences ?? TestFixtures.preferences()
|
||||
|
||||
// Calculate totals from stops
|
||||
let totalGames = actualStops.reduce(0) { $0 + $1.games.count }
|
||||
|
||||
return Trip(
|
||||
name: name,
|
||||
preferences: actualPrefs,
|
||||
stops: actualStops,
|
||||
totalGames: totalGames,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - RichGame Factory
|
||||
|
||||
/// Creates a RichGame with resolved team and stadium references.
|
||||
static func richGame(
|
||||
game: Game? = nil,
|
||||
homeCity: String = "New York",
|
||||
awayCity: String = "Boston",
|
||||
sport: Sport = .mlb
|
||||
) -> RichGame {
|
||||
let actualGame = game ?? TestFixtures.game(sport: sport, city: homeCity)
|
||||
let homeTeam = team(sport: sport, city: homeCity)
|
||||
let awayTeam = team(sport: sport, city: awayCity)
|
||||
let gameStadium = stadium(city: homeCity, sport: sport)
|
||||
|
||||
return RichGame(
|
||||
game: actualGame,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
stadium: gameStadium
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TripScore Factory
|
||||
|
||||
/// Creates a TripScore with customizable component scores.
|
||||
static func tripScore(
|
||||
overall: Double = 85.0,
|
||||
gameQuality: Double = 90.0,
|
||||
routeEfficiency: Double = 80.0,
|
||||
leisureBalance: Double = 85.0,
|
||||
preferenceAlignment: Double = 85.0
|
||||
) -> TripScore {
|
||||
TripScore(
|
||||
overallScore: overall,
|
||||
gameQualityScore: gameQuality,
|
||||
routeEfficiencyScore: routeEfficiency,
|
||||
leisureBalanceScore: leisureBalance,
|
||||
preferenceAlignmentScore: preferenceAlignment
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Date Helpers
|
||||
|
||||
/// Creates a date at a specific time (for testing time-sensitive logic).
|
||||
static func date(
|
||||
year: Int = 2026,
|
||||
month: Int = 6,
|
||||
day: Int = 15,
|
||||
hour: Int = 19,
|
||||
minute: Int = 5
|
||||
) -> Date {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
components.timeZone = TimeZone(identifier: "America/New_York")
|
||||
return Calendar.current.date(from: components)!
|
||||
}
|
||||
|
||||
/// Creates dates for a range of days.
|
||||
static func dateRange(start: Date = Date(), days: Int) -> (start: Date, end: Date) {
|
||||
let end = Calendar.current.date(byAdding: .day, value: days, to: start)!
|
||||
return (start, end)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Haversine distance calculation (returns miles).
|
||||
private static func haversineDistance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let R = 3958.8 // Earth radius in miles
|
||||
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 R * c
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinate Constants for Testing
|
||||
|
||||
extension TestFixtures {
|
||||
|
||||
/// Known distances between cities (in miles) for validation.
|
||||
static let knownDistances: [(from: String, to: String, miles: Double)] = [
|
||||
("New York", "Boston", 215),
|
||||
("New York", "Chicago", 790),
|
||||
("New York", "Los Angeles", 2790),
|
||||
("Chicago", "Denver", 1000),
|
||||
("Los Angeles", "San Francisco", 380),
|
||||
("Seattle", "Los Angeles", 1135),
|
||||
]
|
||||
|
||||
/// Cities clearly in each region for boundary testing.
|
||||
static let eastCoastCities = ["New York", "Boston", "Miami", "Atlanta", "Philadelphia"]
|
||||
static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"]
|
||||
static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"]
|
||||
}
|
||||
Reference in New Issue
Block a user