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>
619 lines
25 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|