Files
Sportstime/SportsTimeTests/Fixtures/FixtureGenerator.swift
Trey t 1703ca5b0f refactor: change domain model IDs from UUID to String canonical IDs
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>
2026-01-12 09:24:33 -06:00

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)
}
}