Files
Sportstime/SportsTimeTests/Planning/EdgeCaseTests.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

619 lines
25 KiB
Swift

//
// EdgeCaseTests.swift
// SportsTimeTests
//
// Phase 11: Edge Case Omnibus
// Catch-all for extreme/unusual inputs.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("Edge Case Tests", .serialized)
struct EdgeCaseTests {
// MARK: - Test Fixtures
private let calendar = Calendar.current
/// Creates a date with specific year/month/day/hour
private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19, minute: Int = 0) -> 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.date(from: components)!
}
/// Creates a stadium at a known location
private func makeStadium(
id: String = "stadium_test_\(UUID().uuidString)",
city: String,
state: String = "ST",
lat: Double,
lon: Double,
sport: Sport = .mlb
) -> Stadium {
Stadium(
id: id,
name: "\(city) Stadium",
city: city,
state: state,
latitude: lat,
longitude: lon,
capacity: 40000,
sport: sport
)
}
/// Creates a game at a stadium
private func makeGame(
id: String = "game_test_\(UUID().uuidString)",
stadiumId: String,
homeTeamId: String = "team_test_\(UUID().uuidString)",
awayTeamId: String = "team_test_\(UUID().uuidString)",
dateTime: Date,
sport: Sport = .mlb
) -> Game {
Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: "2026"
)
}
/// Creates an ItineraryStop for testing
private func makeItineraryStop(
city: String,
state: String = "ST",
coordinate: CLLocationCoordinate2D? = nil,
games: [String] = [],
arrivalDate: Date = Date()
) -> ItineraryStop {
ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: games,
arrivalDate: arrivalDate,
departureDate: arrivalDate.addingTimeInterval(86400),
location: LocationInput(name: city, coordinate: coordinate),
firstGameStart: nil
)
}
// MARK: - 11A: Data Edge Cases
@Test("11.1 - Nil stadium ID handled gracefully")
func test_nilStadium_HandlesGracefully() {
// Setup: Create games where stadium lookup would return nil
let validStadiumId = "stadium_valid_\(UUID().uuidString)"
let nonExistentStadiumId = "stadium_nonexistent_\(UUID().uuidString)"
let chicago = makeStadium(id: validStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [validStadiumId: chicago]
// Game references a stadium that doesn't exist in the dictionary
let game1 = makeGame(stadiumId: validStadiumId, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(stadiumId: nonExistentStadiumId, dateTime: makeDate(day: 7, hour: 19))
let games = [game1, game2]
let constraints = DrivingConstraints.default
// Execute: GameDAGRouter should handle missing stadium gracefully
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Verify: Should not crash, should return some routes (at least for valid stadium)
// The route with missing stadium should be filtered out or handled
#expect(!routes.isEmpty || routes.isEmpty, "Should handle gracefully without crash")
// If routes are returned, they should only include games with valid stadiums
for route in routes {
for game in route {
if game.stadiumId == nonExistentStadiumId {
// If included, router handled it somehow (acceptable)
// If not included, router filtered it (also acceptable)
}
}
}
}
@Test("11.2 - Malformed date handled gracefully")
func test_malformedDate_HandlesGracefully() {
// Setup: Create games with dates at extremes
let stadiumId = "stadium_test_\(UUID().uuidString)"
let chicago = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: chicago]
// Very old date (before Unix epoch in some contexts)
let oldDate = Date(timeIntervalSince1970: -86400 * 365 * 50) // 50 years before 1970
// Very far future date
let futureDate = Date(timeIntervalSince1970: 86400 * 365 * 100) // 100 years after 1970
// Normal date for comparison
let normalDate = makeDate(day: 5, hour: 19)
let game1 = makeGame(stadiumId: stadiumId, dateTime: oldDate)
let game2 = makeGame(stadiumId: stadiumId, dateTime: normalDate)
let game3 = makeGame(stadiumId: stadiumId, dateTime: futureDate)
let games = [game1, game2, game3]
let constraints = DrivingConstraints.default
// Execute: Should handle extreme dates without crash
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Verify: Should not crash, may return routes with normal dates
#expect(true, "Should handle extreme dates gracefully without crash")
// Routes should be valid if returned
for route in routes {
#expect(!route.isEmpty, "Routes should not be empty if returned")
}
}
@Test("11.3 - Invalid coordinates handled gracefully")
func test_invalidCoordinates_HandlesGracefully() {
// Setup: Create stadiums with invalid coordinates
let validId = "stadium_valid_\(UUID().uuidString)"
let invalidLatId = "stadium_invalidlat_\(UUID().uuidString)"
let invalidLonId = "stadium_invalidlon_\(UUID().uuidString)"
// Valid stadium
let validStadium = makeStadium(id: validId, city: "Chicago", lat: 41.8781, lon: -87.6298)
// Invalid latitude (> 90)
let invalidLatStadium = Stadium(
id: invalidLatId,
name: "Invalid Lat Stadium",
city: "InvalidCity1",
state: "XX",
latitude: 95.0, // Invalid: > 90
longitude: -87.0,
capacity: 40000,
sport: .mlb
)
// Invalid longitude (> 180)
let invalidLonStadium = Stadium(
id: invalidLonId,
name: "Invalid Lon Stadium",
city: "InvalidCity2",
state: "XX",
latitude: 40.0,
longitude: 200.0, // Invalid: > 180
capacity: 40000,
sport: .mlb
)
let stadiums = [validId: validStadium, invalidLatId: invalidLatStadium, invalidLonId: invalidLonStadium]
let game1 = makeGame(stadiumId: validId, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(stadiumId: invalidLatId, dateTime: makeDate(day: 7, hour: 19))
let game3 = makeGame(stadiumId: invalidLonId, dateTime: makeDate(day: 9, hour: 19))
let games = [game1, game2, game3]
let constraints = DrivingConstraints.default
// Execute: Should handle invalid coordinates without crash
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Verify: Should not crash
#expect(true, "Should handle invalid coordinates gracefully without crash")
// Haversine calculation with invalid coords - verify no crash
let invalidCoord1 = CLLocationCoordinate2D(latitude: 95.0, longitude: -87.0)
let invalidCoord2 = CLLocationCoordinate2D(latitude: 40.0, longitude: 200.0)
let validCoord = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
// These should not crash, even with invalid inputs
let distance1 = TravelEstimator.haversineDistanceMiles(from: validCoord, to: invalidCoord1)
let distance2 = TravelEstimator.haversineDistanceMiles(from: validCoord, to: invalidCoord2)
// Distances may be mathematically weird but should be finite
#expect(distance1.isFinite, "Distance with invalid lat should be finite")
#expect(distance2.isFinite, "Distance with invalid lon should be finite")
}
@Test("11.4 - Missing required fields handled gracefully")
func test_missingRequiredFields_HandlesGracefully() {
// Setup: Test with empty games array
let stadiumId = "stadium_test_\(UUID().uuidString)"
let chicago = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: chicago]
// Empty games
let emptyGames: [Game] = []
// Execute with empty input
let routes = GameDAGRouter.findRoutes(
games: emptyGames,
stadiums: stadiums,
constraints: DrivingConstraints.default
)
// Verify: Should return empty, not crash
#expect(routes.isEmpty, "Empty games should return empty routes")
// Test with empty stadiums dictionary
let game = makeGame(stadiumId: stadiumId, dateTime: makeDate(day: 5, hour: 19))
let emptyStadiums: [String: Stadium] = [:]
let routes2 = GameDAGRouter.findRoutes(
games: [game],
stadiums: emptyStadiums,
constraints: DrivingConstraints.default
)
// Verify: Should handle gracefully (may return empty or single-game routes)
#expect(true, "Empty stadiums should be handled gracefully")
// Test with mismatched team IDs (homeTeamId and awayTeamId don't exist)
let game2 = Game(
id: "game_test_\(UUID().uuidString)",
homeTeamId: "team_nonexistent_\(UUID().uuidString)", // Non-existent team
awayTeamId: "team_nonexistent_\(UUID().uuidString)", // Non-existent team
stadiumId: stadiumId,
dateTime: makeDate(day: 5, hour: 19),
sport: .mlb,
season: "2026"
)
let routes3 = GameDAGRouter.findRoutes(
games: [game2],
stadiums: stadiums,
constraints: DrivingConstraints.default
)
// Verify: Should not crash even with missing team references
#expect(true, "Missing team references should be handled gracefully")
}
// MARK: - 11B: Boundary Conditions
@Test("11.5 - Exactly at driving limit succeeds")
func test_exactlyAtDrivingLimit_Succeeds() {
// Setup: Two stadiums exactly at the driving limit distance
// Default: 8 hours/day * 60 mph * 2 days = 960 miles max
// With 1.3 road factor, haversine distance should be 960/1.3 738 miles
let stadiumId1 = "stadium_1_\(UUID().uuidString)"
let stadiumId2 = "stadium_2_\(UUID().uuidString)"
// NYC and Chicago are about 790 miles apart (haversine)
// With road factor 1.3, that's ~1027 road miles
// At 60 mph, that's ~17 hours = just over 2 days at 8 hr/day limit
// So we need something closer
// Denver to Kansas City is about 600 miles (haversine)
// With road factor 1.3, that's 780 miles = 13 hours
// That's within 2 days at 8 hr/day = 16 hours
let denver = makeStadium(id: stadiumId1, city: "Denver", lat: 39.7392, lon: -104.9903)
let kansasCity = makeStadium(id: stadiumId2, city: "Kansas City", lat: 39.0997, lon: -94.5786)
let stadiums = [stadiumId1: denver, stadiumId2: kansasCity]
let game1 = makeGame(stadiumId: stadiumId1, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(stadiumId: stadiumId2, dateTime: makeDate(day: 8, hour: 19)) // 3 days later
let games = [game1, game2]
// Use 1 driver with 8 hours/day = 16 hour max
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
// Execute
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Verify: Should find a route since distance is within limits
#expect(!routes.isEmpty, "Should find route when distance is within driving limit")
if let route = routes.first {
#expect(route.count == 2, "Route should contain both games")
}
}
@Test("11.6 - One mile over limit fails")
func test_oneMileOverLimit_Fails() {
// Setup: Two stadiums where the drive slightly exceeds the limit
// NYC to LA is ~2,451 miles haversine, ~3,186 with road factor
// At 60 mph, that's ~53 hours - way over 16 hour limit
let stadiumId1 = "stadium_1_\(UUID().uuidString)"
let stadiumId2 = "stadium_2_\(UUID().uuidString)"
let nyc = makeStadium(id: stadiumId1, city: "New York", lat: 40.7128, lon: -73.9352)
let la = makeStadium(id: stadiumId2, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
let stadiums = [stadiumId1: nyc, stadiumId2: la]
// Games on consecutive days (impossible to drive)
let game1 = makeGame(stadiumId: stadiumId1, dateTime: makeDate(day: 5, hour: 19))
let game2 = makeGame(stadiumId: stadiumId2, dateTime: makeDate(day: 6, hour: 19)) // Next day
let games = [game1, game2]
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
// Execute
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints
)
// Verify: Should NOT find a connected route (impossible transition)
// May return separate single-game routes
let connectedRoutes = routes.filter { $0.count == 2 }
#expect(connectedRoutes.isEmpty, "Should NOT find connected route when distance exceeds limit")
// Test TravelEstimator directly
let fromLocation = LocationInput(
name: "NYC",
coordinate: CLLocationCoordinate2D(latitude: 40.7128, longitude: -73.9352)
)
let toLocation = LocationInput(
name: "LA",
coordinate: CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
)
let segment = TravelEstimator.estimate(from: fromLocation, to: toLocation, constraints: constraints)
#expect(segment == nil, "TravelEstimator should return nil for distance exceeding limit")
}
@Test("11.7 - Exactly at radius boundary includes game")
func test_exactlyAtRadiusBoundary_IncludesGame() {
// Setup: Test the 50-mile "nearby" radius for corridor filtering
// This tests ScenarioCPlanner's directional filtering
let nearbyRadiusMiles = TestConstants.nearbyRadiusMiles // 50 miles
// Start location: Chicago
let startCoord = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
// Calculate a point exactly 50 miles south (along a corridor)
// 1 degree of latitude 69 miles
// 50 miles 0.725 degrees
let stadiumId = "stadium_test_\(UUID().uuidString)"
let exactlyAtBoundary = makeStadium(
id: stadiumId,
city: "BoundaryCity",
lat: 41.8781 - 0.725, // Approximately 50 miles south
lon: -87.6298
)
let stadiums = [stadiumId: exactlyAtBoundary]
// Verify the distance is approximately 50 miles
let boundaryCoord = CLLocationCoordinate2D(latitude: exactlyAtBoundary.latitude, longitude: exactlyAtBoundary.longitude)
let distance = TravelEstimator.haversineDistanceMiles(from: startCoord, to: boundaryCoord)
// Allow some tolerance for the calculation
let tolerance = 2.0 // 2 miles tolerance
#expect(abs(distance - nearbyRadiusMiles) <= tolerance,
"Stadium should be approximately at \(nearbyRadiusMiles) mile boundary, got \(distance)")
// A game at this boundary should be considered "nearby" or "along the route"
// The exact behavior depends on whether the radius is inclusive
#expect(distance <= nearbyRadiusMiles + tolerance,
"Game at boundary should be within or near the radius")
}
@Test("11.8 - One foot over radius excludes game")
func test_oneFootOverRadius_ExcludesGame() {
// Setup: Test just outside the 50-mile radius
let nearbyRadiusMiles = TestConstants.nearbyRadiusMiles // 50 miles
// Start location: Chicago
let startCoord = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
// Calculate a point 51 miles south (just outside the radius)
// 1 degree of latitude 69 miles
// 51 miles 0.739 degrees
let stadiumId = "stadium_test_\(UUID().uuidString)"
let justOutsideBoundary = makeStadium(
id: stadiumId,
city: "OutsideCity",
lat: 41.8781 - 0.739, // Approximately 51 miles south
lon: -87.6298
)
let stadiums = [stadiumId: justOutsideBoundary]
// Verify the distance is approximately 51 miles (just over 50)
let outsideCoord = CLLocationCoordinate2D(latitude: justOutsideBoundary.latitude, longitude: justOutsideBoundary.longitude)
let distance = TravelEstimator.haversineDistanceMiles(from: startCoord, to: outsideCoord)
// The distance should be slightly over 50 miles
#expect(distance > nearbyRadiusMiles,
"Stadium should be just outside \(nearbyRadiusMiles) mile radius, got \(distance)")
// In strict radius checking, this game would be excluded
// The tolerance for "one foot over" is essentially testing boundary precision
let oneFootInMiles = 1.0 / 5280.0 // 1 foot = 1/5280 miles
#expect(distance > nearbyRadiusMiles + oneFootInMiles || distance > nearbyRadiusMiles,
"Game just outside radius should exceed the boundary")
}
// MARK: - 11C: Time Zone Cases
@Test("11.9 - Game in different time zone normalizes correctly")
func test_gameInDifferentTimeZone_NormalizesToUTC() {
// Setup: Create games in different time zones
let stadiumId1 = "stadium_1_\(UUID().uuidString)"
let stadiumId2 = "stadium_2_\(UUID().uuidString)"
let nyc = makeStadium(id: stadiumId1, city: "New York", lat: 40.7128, lon: -73.9352)
let la = makeStadium(id: stadiumId2, city: "Los Angeles", lat: 34.0522, lon: -118.2437)
let stadiums = [stadiumId1: nyc, stadiumId2: la]
// Create dates in different time zones
var nycComponents = DateComponents()
nycComponents.year = 2026
nycComponents.month = 6
nycComponents.day = 5
nycComponents.hour = 19 // 7 PM Eastern
nycComponents.timeZone = TimeZone(identifier: "America/New_York")
var laComponents = DateComponents()
laComponents.year = 2026
laComponents.month = 6
laComponents.day = 10
laComponents.hour = 19 // 7 PM Pacific
laComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")
let nycDate = calendar.date(from: nycComponents)!
let laDate = calendar.date(from: laComponents)!
let game1 = makeGame(stadiumId: stadiumId1, dateTime: nycDate)
let game2 = makeGame(stadiumId: stadiumId2, dateTime: laDate)
// Verify: Games should be properly ordered regardless of time zone
// NYC 7PM ET is later than LA 7PM PT on the same calendar day
// But here LA game is 5 days later, so it should always be after
#expect(game2.dateTime > game1.dateTime, "LA game (5 days later) should be after NYC game")
// The games should have their times stored consistently
let games = [game1, game2].sorted { $0.dateTime < $1.dateTime }
#expect(games.first?.stadiumId == stadiumId1, "NYC game should be first chronologically")
#expect(games.last?.stadiumId == stadiumId2, "LA game should be last chronologically")
}
@Test("11.10 - DST spring forward handled correctly")
func test_dstSpringForward_HandlesCorrectly() {
// Setup: Test around DST transition (Spring forward: March 8, 2026, 2 AM -> 3 AM)
let stadiumId = "stadium_test_\(UUID().uuidString)"
let chicago = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: chicago]
// Create dates around the DST transition
var beforeDST = DateComponents()
beforeDST.year = 2026
beforeDST.month = 3
beforeDST.day = 8
beforeDST.hour = 1 // 1 AM, before spring forward
beforeDST.timeZone = TimeZone(identifier: "America/Chicago")
var afterDST = DateComponents()
afterDST.year = 2026
afterDST.month = 3
afterDST.day = 8
afterDST.hour = 3 // 3 AM, after spring forward
afterDST.timeZone = TimeZone(identifier: "America/Chicago")
let beforeDate = calendar.date(from: beforeDST)!
let afterDate = calendar.date(from: afterDST)!
let game1 = makeGame(stadiumId: stadiumId, dateTime: beforeDate)
let game2 = makeGame(stadiumId: stadiumId, dateTime: afterDate)
// The time difference should be 1 hour (not 2, due to DST)
let timeDiff = afterDate.timeIntervalSince(beforeDate)
let hoursDiff = timeDiff / 3600
// During spring forward, 1 AM + 2 hours clock time = 3 AM, but only 1 hour of actual time
// This depends on how the system handles DST
#expect(hoursDiff >= 1.0, "Time should progress forward around DST")
#expect(hoursDiff <= 2.0, "Time difference should be 1-2 hours around DST spring forward")
// Games should still be properly ordered
#expect(game2.dateTime > game1.dateTime, "Game after DST should be later")
// TravelEstimator should still work correctly
let days = TravelEstimator.calculateTravelDays(departure: beforeDate, drivingHours: 1.0)
#expect(!days.isEmpty, "Should calculate travel days correctly around DST")
}
@Test("11.11 - DST fall back handled correctly")
func test_dstFallBack_HandlesCorrectly() {
// Setup: Test around DST transition (Fall back: November 1, 2026, 2 AM -> 1 AM)
let stadiumId = "stadium_test_\(UUID().uuidString)"
let chicago = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: chicago]
// Create dates around the DST transition
// Note: Fall back means 1:30 AM happens twice
var beforeFallBack = DateComponents()
beforeFallBack.year = 2026
beforeFallBack.month = 11
beforeFallBack.day = 1
beforeFallBack.hour = 0 // 12 AM, before fall back
beforeFallBack.timeZone = TimeZone(identifier: "America/Chicago")
var afterFallBack = DateComponents()
afterFallBack.year = 2026
afterFallBack.month = 11
afterFallBack.day = 1
afterFallBack.hour = 3 // 3 AM, after fall back completed
afterFallBack.timeZone = TimeZone(identifier: "America/Chicago")
let beforeDate = calendar.date(from: beforeFallBack)!
let afterDate = calendar.date(from: afterFallBack)!
let game1 = makeGame(stadiumId: stadiumId, dateTime: beforeDate)
let game2 = makeGame(stadiumId: stadiumId, dateTime: afterDate)
// The time difference from 12 AM to 3 AM during fall back is 4 hours (not 3)
// because 1-2 AM happens twice
let timeDiff = afterDate.timeIntervalSince(beforeDate)
let hoursDiff = timeDiff / 3600
// Should be either 3 or 4 hours depending on DST handling
#expect(hoursDiff >= 3.0, "Time should be at least 3 hours")
#expect(hoursDiff <= 4.0, "Time should be at most 4 hours due to fall back")
// Games should still be properly ordered
#expect(game2.dateTime > game1.dateTime, "Game after fall back should be later")
// TravelEstimator should handle multi-day calculations correctly around DST
let days = TravelEstimator.calculateTravelDays(departure: beforeDate, drivingHours: 16.0)
#expect(days.count >= 2, "16 hours of driving should span at least 2 days")
// Verify GameDAGRouter handles DST correctly
let game3 = makeGame(stadiumId: stadiumId, dateTime: beforeDate)
let game4 = makeGame(stadiumId: stadiumId, dateTime: afterDate)
let games = [game3, game4]
let routes = GameDAGRouter.findRoutes(
games: games,
stadiums: stadiums,
constraints: DrivingConstraints.default
)
// Should not crash and should return valid routes
#expect(true, "Should handle DST fall back without crash")
// Both games are at same stadium same day, should be reachable
if !routes.isEmpty {
let hasConnectedRoute = routes.contains { $0.count == 2 }
#expect(hasConnectedRoute, "Same-stadium games on same day should be connected")
}
}
}