This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
491 lines
18 KiB
Swift
491 lines
18 KiB
Swift
//
|
|
// FixtureGenerator.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Generates synthetic test data for unit and integration tests.
|
|
// Uses deterministic seeding for reproducible test results.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
// MARK: - Random Number Generator with Seed
|
|
|
|
struct SeededRandomNumberGenerator: RandomNumberGenerator {
|
|
private var state: UInt64
|
|
|
|
init(seed: UInt64) {
|
|
self.state = seed
|
|
}
|
|
|
|
mutating func next() -> UInt64 {
|
|
// xorshift64 algorithm for reproducibility
|
|
state ^= state << 13
|
|
state ^= state >> 7
|
|
state ^= state << 17
|
|
return state
|
|
}
|
|
}
|
|
|
|
// MARK: - Fixture Generator
|
|
|
|
struct FixtureGenerator {
|
|
|
|
// MARK: - Configuration
|
|
|
|
struct Configuration {
|
|
var seed: UInt64 = 12345
|
|
var gameCount: Int = 50
|
|
var stadiumCount: Int = 30
|
|
var teamCount: Int = 30
|
|
var dateRange: ClosedRange<Date> = {
|
|
let start = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 1))!
|
|
let end = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 30))!
|
|
return start...end
|
|
}()
|
|
var sports: Set<Sport> = [.mlb, .nba, .nhl]
|
|
var geographicSpread: GeographicSpread = .nationwide
|
|
|
|
enum GeographicSpread {
|
|
case nationwide // Full US coverage
|
|
case regional // Concentrated in one region
|
|
case corridor // Along a route (e.g., East Coast)
|
|
case cluster // Single metro area
|
|
}
|
|
|
|
static var `default`: Configuration { Configuration() }
|
|
static var minimal: Configuration { Configuration(gameCount: 5, stadiumCount: 5, teamCount: 5) }
|
|
static var small: Configuration { Configuration(gameCount: 50, stadiumCount: 15, teamCount: 15) }
|
|
static var medium: Configuration { Configuration(gameCount: 500, stadiumCount: 30, teamCount: 30) }
|
|
static var large: Configuration { Configuration(gameCount: 2000, stadiumCount: 30, teamCount: 60) }
|
|
static var stress: Configuration { Configuration(gameCount: 10000, stadiumCount: 30, teamCount: 60) }
|
|
}
|
|
|
|
// MARK: - Generated Data Container
|
|
|
|
struct GeneratedData {
|
|
let stadiums: [Stadium]
|
|
let teams: [Team]
|
|
let games: [Game]
|
|
let stadiumsById: [String: Stadium]
|
|
let teamsById: [String: Team]
|
|
|
|
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)
|
|
}
|
|
|
|
func richGames() -> [RichGame] {
|
|
games.compactMap { richGame(from: $0) }
|
|
}
|
|
}
|
|
|
|
// MARK: - City Data for Realistic Generation
|
|
|
|
private static let cityData: [(name: String, state: String, lat: Double, lon: Double, region: Region)] = [
|
|
// East Coast
|
|
("New York", "NY", 40.7128, -73.9352, .east),
|
|
("Boston", "MA", 42.3601, -71.0589, .east),
|
|
("Philadelphia", "PA", 39.9526, -75.1652, .east),
|
|
("Washington", "DC", 38.9072, -77.0369, .east),
|
|
("Baltimore", "MD", 39.2904, -76.6122, .east),
|
|
("Miami", "FL", 25.7617, -80.1918, .east),
|
|
("Tampa", "FL", 27.9506, -82.4572, .east),
|
|
("Atlanta", "GA", 33.7490, -84.3880, .east),
|
|
("Charlotte", "NC", 35.2271, -80.8431, .east),
|
|
("Pittsburgh", "PA", 40.4406, -79.9959, .east),
|
|
|
|
// Central
|
|
("Chicago", "IL", 41.8781, -87.6298, .central),
|
|
("Detroit", "MI", 42.3314, -83.0458, .central),
|
|
("Cleveland", "OH", 41.4993, -81.6944, .central),
|
|
("Cincinnati", "OH", 39.1031, -84.5120, .central),
|
|
("Milwaukee", "WI", 43.0389, -87.9065, .central),
|
|
("Minneapolis", "MN", 44.9778, -93.2650, .central),
|
|
("St. Louis", "MO", 38.6270, -90.1994, .central),
|
|
("Kansas City", "MO", 39.0997, -94.5786, .central),
|
|
("Dallas", "TX", 32.7767, -96.7970, .central),
|
|
("Houston", "TX", 29.7604, -95.3698, .central),
|
|
|
|
// West Coast
|
|
("Los Angeles", "CA", 34.0522, -118.2437, .west),
|
|
("San Francisco", "CA", 37.7749, -122.4194, .west),
|
|
("San Diego", "CA", 32.7157, -117.1611, .west),
|
|
("Seattle", "WA", 47.6062, -122.3321, .west),
|
|
("Portland", "OR", 45.5152, -122.6784, .west),
|
|
("Phoenix", "AZ", 33.4484, -112.0740, .west),
|
|
("Denver", "CO", 39.7392, -104.9903, .west),
|
|
("Salt Lake City", "UT", 40.7608, -111.8910, .west),
|
|
("Las Vegas", "NV", 36.1699, -115.1398, .west),
|
|
("Oakland", "CA", 37.8044, -122.2712, .west),
|
|
]
|
|
|
|
private static let teamNames = [
|
|
"Eagles", "Tigers", "Bears", "Lions", "Panthers",
|
|
"Hawks", "Wolves", "Sharks", "Dragons", "Knights",
|
|
"Royals", "Giants", "Cardinals", "Mariners", "Brewers",
|
|
"Rangers", "Padres", "Dodgers", "Mets", "Yankees",
|
|
"Cubs", "Sox", "Twins", "Rays", "Marlins",
|
|
"Nationals", "Braves", "Reds", "Pirates", "Orioles"
|
|
]
|
|
|
|
// MARK: - Generation
|
|
|
|
static func generate(with config: Configuration = .default) -> GeneratedData {
|
|
var rng = SeededRandomNumberGenerator(seed: config.seed)
|
|
|
|
// Generate stadiums
|
|
let stadiums = generateStadiums(config: config, rng: &rng)
|
|
let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
|
|
|
|
// Generate teams (2 per stadium typically)
|
|
let teams = generateTeams(stadiums: stadiums, config: config, rng: &rng)
|
|
let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
|
|
|
|
// Generate games
|
|
let games = generateGames(teams: teams, stadiums: stadiums, config: config, rng: &rng)
|
|
|
|
return GeneratedData(
|
|
stadiums: stadiums,
|
|
teams: teams,
|
|
games: games,
|
|
stadiumsById: stadiumsById,
|
|
teamsById: teamsById
|
|
)
|
|
}
|
|
|
|
private static func generateStadiums(
|
|
config: Configuration,
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> [Stadium] {
|
|
let cities = selectCities(for: config.geographicSpread, count: config.stadiumCount, rng: &rng)
|
|
|
|
return cities.enumerated().map { index, city in
|
|
let sport = config.sports.randomElement(using: &rng) ?? .mlb
|
|
return Stadium(
|
|
id: "stadium_test_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))_\(index)",
|
|
name: "\(city.name) \(sport.rawValue) Stadium",
|
|
city: city.name,
|
|
state: city.state,
|
|
latitude: city.lat + Double.random(in: -0.05...0.05, using: &rng),
|
|
longitude: city.lon + Double.random(in: -0.05...0.05, using: &rng),
|
|
capacity: Int.random(in: 20000...60000, using: &rng),
|
|
sport: sport,
|
|
yearOpened: Int.random(in: 1990...2024, using: &rng)
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func generateTeams(
|
|
stadiums: [Stadium],
|
|
config: Configuration,
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> [Team] {
|
|
var teams: [Team] = []
|
|
var usedNames = Set<String>()
|
|
|
|
for stadium in stadiums {
|
|
// Each stadium gets 1-2 teams
|
|
let teamCountForStadium = Int.random(in: 1...2, using: &rng)
|
|
|
|
for _ in 0..<teamCountForStadium {
|
|
guard teams.count < config.teamCount else { break }
|
|
|
|
var teamName: String
|
|
repeat {
|
|
teamName = teamNames.randomElement(using: &rng) ?? "Team\(teams.count)"
|
|
} while usedNames.contains("\(stadium.city) \(teamName)")
|
|
|
|
usedNames.insert("\(stadium.city) \(teamName)")
|
|
|
|
let teamId = "team_test_\(stadium.city.lowercased().replacingOccurrences(of: " ", with: "_"))_\(teamName.lowercased())_\(teams.count)"
|
|
teams.append(Team(
|
|
id: teamId,
|
|
name: teamName,
|
|
abbreviation: String(teamName.prefix(3)).uppercased(),
|
|
sport: stadium.sport,
|
|
city: stadium.city,
|
|
stadiumId: stadium.id
|
|
))
|
|
}
|
|
}
|
|
|
|
return teams
|
|
}
|
|
|
|
private static func generateGames(
|
|
teams: [Team],
|
|
stadiums: [Stadium],
|
|
config: Configuration,
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> [Game] {
|
|
var games: [Game] = []
|
|
let calendar = Calendar.current
|
|
|
|
let dateRangeDays = calendar.dateComponents([.day], from: config.dateRange.lowerBound, to: config.dateRange.upperBound).day ?? 180
|
|
|
|
for _ in 0..<config.gameCount {
|
|
guard teams.count >= 2 else { break }
|
|
|
|
// Pick random home team
|
|
let homeTeam = teams.randomElement(using: &rng)!
|
|
|
|
// Pick random away team (different from home)
|
|
var awayTeam: Team
|
|
repeat {
|
|
awayTeam = teams.randomElement(using: &rng)!
|
|
} while awayTeam.id == homeTeam.id
|
|
|
|
// Find home team's stadium
|
|
let stadium = stadiums.first { $0.id == homeTeam.stadiumId } ?? stadiums[0]
|
|
|
|
// Random date within range
|
|
let daysOffset = Int.random(in: 0..<dateRangeDays, using: &rng)
|
|
let gameDate = calendar.date(byAdding: .day, value: daysOffset, to: config.dateRange.lowerBound)!
|
|
|
|
// Random game time (1pm - 9pm)
|
|
let hour = Int.random(in: 13...21, using: &rng)
|
|
let gameDateTime = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: gameDate)!
|
|
|
|
let gameId = "game_test_\(games.count)_\(homeTeam.abbreviation)_\(awayTeam.abbreviation)"
|
|
games.append(Game(
|
|
id: gameId,
|
|
homeTeamId: homeTeam.id,
|
|
awayTeamId: awayTeam.id,
|
|
stadiumId: stadium.id,
|
|
dateTime: gameDateTime,
|
|
sport: homeTeam.sport,
|
|
season: "2026",
|
|
isPlayoff: Double.random(in: 0...1, using: &rng) < 0.1 // 10% playoff games
|
|
))
|
|
}
|
|
|
|
return games.sorted { $0.dateTime < $1.dateTime }
|
|
}
|
|
|
|
private static func selectCities(
|
|
for spread: Configuration.GeographicSpread,
|
|
count: Int,
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> [(name: String, state: String, lat: Double, lon: Double, region: Region)] {
|
|
let cities: [(name: String, state: String, lat: Double, lon: Double, region: Region)]
|
|
|
|
switch spread {
|
|
case .nationwide:
|
|
cities = cityData.shuffled(using: &rng)
|
|
case .regional:
|
|
let region = Region.allCases.randomElement(using: &rng) ?? .east
|
|
cities = cityData.filter { $0.region == region }.shuffled(using: &rng)
|
|
case .corridor:
|
|
// East Coast corridor
|
|
cities = cityData.filter { $0.region == .east }.shuffled(using: &rng)
|
|
case .cluster:
|
|
// Just pick one city and create variations
|
|
let baseCity = cityData.randomElement(using: &rng)!
|
|
cities = (0..<count).map { i in
|
|
(
|
|
name: "\(baseCity.name) \(i)",
|
|
state: baseCity.state,
|
|
lat: baseCity.lat + Double.random(in: -0.5...0.5, using: &rng),
|
|
lon: baseCity.lon + Double.random(in: -0.5...0.5, using: &rng),
|
|
region: baseCity.region
|
|
)
|
|
}
|
|
return Array(cities.prefix(count))
|
|
}
|
|
|
|
return Array(cities.prefix(count))
|
|
}
|
|
|
|
// MARK: - Specialized Generators
|
|
|
|
/// Generate a trip with specific configuration
|
|
static func generateTrip(
|
|
stops: Int = 5,
|
|
startDate: Date = Date(),
|
|
preferences: TripPreferences? = nil,
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> Trip {
|
|
let prefs = preferences ?? TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: Calendar.current.date(byAdding: .day, value: stops * 2, to: startDate)!
|
|
)
|
|
|
|
var tripStops: [TripStop] = []
|
|
var currentDate = startDate
|
|
|
|
for i in 0..<stops {
|
|
let city = cityData.randomElement(using: &rng)!
|
|
let departureDate = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
|
|
|
|
tripStops.append(TripStop(
|
|
id: UUID(),
|
|
stopNumber: i + 1,
|
|
city: city.name,
|
|
state: city.state,
|
|
coordinate: CLLocationCoordinate2D(latitude: city.lat, longitude: city.lon),
|
|
arrivalDate: currentDate,
|
|
departureDate: departureDate,
|
|
games: ["game_test_\(i)"],
|
|
stadium: "stadium_test_\(i)"
|
|
))
|
|
|
|
currentDate = departureDate
|
|
}
|
|
|
|
return Trip(
|
|
name: "Test Trip",
|
|
preferences: prefs,
|
|
stops: tripStops
|
|
)
|
|
}
|
|
|
|
/// Generate games specifically for testing same-day conflicts
|
|
static func generateConflictingGames(
|
|
date: Date,
|
|
cities: [(name: String, lat: Double, lon: Double)],
|
|
rng: inout SeededRandomNumberGenerator
|
|
) -> [Game] {
|
|
cities.enumerated().map { index, city in
|
|
let stadiumId = "stadium_conflict_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))_\(index)"
|
|
return Game(
|
|
id: "game_conflict_\(index)_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))",
|
|
homeTeamId: "team_conflict_home_\(index)",
|
|
awayTeamId: "team_conflict_away_\(index)",
|
|
stadiumId: stadiumId,
|
|
dateTime: date,
|
|
sport: .mlb,
|
|
season: "2026"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Generate a stadium at a specific location
|
|
static func makeStadium(
|
|
id: String = "stadium_test_\(UUID().uuidString)",
|
|
name: String = "Test Stadium",
|
|
city: String = "Test City",
|
|
state: String = "TS",
|
|
latitude: Double = 40.0,
|
|
longitude: Double = -100.0,
|
|
capacity: Int = 40000,
|
|
sport: Sport = .mlb
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: name,
|
|
city: city,
|
|
state: state,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
capacity: capacity,
|
|
sport: sport
|
|
)
|
|
}
|
|
|
|
/// Generate a team
|
|
static func makeTeam(
|
|
id: String = "team_test_\(UUID().uuidString)",
|
|
name: String = "Test Team",
|
|
abbreviation: String = "TST",
|
|
sport: Sport = .mlb,
|
|
city: String = "Test City",
|
|
stadiumId: String
|
|
) -> Team {
|
|
Team(
|
|
id: id,
|
|
name: name,
|
|
abbreviation: abbreviation,
|
|
sport: sport,
|
|
city: city,
|
|
stadiumId: stadiumId
|
|
)
|
|
}
|
|
|
|
/// Generate a game
|
|
static func makeGame(
|
|
id: String = "game_test_\(UUID().uuidString)",
|
|
homeTeamId: String,
|
|
awayTeamId: String,
|
|
stadiumId: String,
|
|
dateTime: Date = Date(),
|
|
sport: Sport = .mlb,
|
|
season: String = "2026",
|
|
isPlayoff: Bool = false
|
|
) -> Game {
|
|
Game(
|
|
id: id,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId,
|
|
stadiumId: stadiumId,
|
|
dateTime: dateTime,
|
|
sport: sport,
|
|
season: season,
|
|
isPlayoff: isPlayoff
|
|
)
|
|
}
|
|
|
|
/// Generate a travel segment
|
|
static func makeTravelSegment(
|
|
from: LocationInput,
|
|
to: LocationInput,
|
|
distanceMiles: Double = 100,
|
|
durationHours: Double = 2
|
|
) -> TravelSegment {
|
|
TravelSegment(
|
|
fromLocation: from,
|
|
toLocation: to,
|
|
travelMode: .drive,
|
|
distanceMeters: distanceMiles * TestConstants.metersPerMile,
|
|
durationSeconds: durationHours * 3600
|
|
)
|
|
}
|
|
|
|
/// Generate a trip stop
|
|
static func makeTripStop(
|
|
stopNumber: Int = 1,
|
|
city: String = "Test City",
|
|
state: String = "TS",
|
|
coordinate: CLLocationCoordinate2D? = nil,
|
|
arrivalDate: Date = Date(),
|
|
departureDate: Date? = nil,
|
|
games: [String] = [],
|
|
stadium: String? = nil,
|
|
isRestDay: Bool = false
|
|
) -> TripStop {
|
|
TripStop(
|
|
stopNumber: stopNumber,
|
|
city: city,
|
|
state: state,
|
|
coordinate: coordinate,
|
|
arrivalDate: arrivalDate,
|
|
departureDate: departureDate ?? Calendar.current.date(byAdding: .day, value: 1, to: arrivalDate)!,
|
|
games: games,
|
|
stadium: stadium,
|
|
isRestDay: isRestDay
|
|
)
|
|
}
|
|
|
|
// MARK: - Known Locations for Testing
|
|
|
|
struct KnownLocations {
|
|
static let nyc = CLLocationCoordinate2D(latitude: 40.7128, longitude: -73.9352)
|
|
static let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
|
|
static let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
|
static let boston = CLLocationCoordinate2D(latitude: 42.3601, longitude: -71.0589)
|
|
static let miami = CLLocationCoordinate2D(latitude: 25.7617, longitude: -80.1918)
|
|
static let seattle = CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321)
|
|
static let denver = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903)
|
|
|
|
// Antipodal point (for testing haversine at extreme distances)
|
|
static let antipodal = CLLocationCoordinate2D(latitude: -40.7128, longitude: 106.0648)
|
|
}
|
|
}
|