Files
Sportstime/SportsTimeTests/Planning/ItineraryBuilderTests.swift
Trey t 1bd248c255 test(planning): complete test suite with Phase 11 edge cases
Implement comprehensive test infrastructure and all 124 tests across 11 phases:

- Phase 0: Test infrastructure (fixtures, mocks, helpers)
- Phases 1-10: Core planning engine tests (previously implemented)
- Phase 11: Edge case omnibus (11 new tests)
  - Data edge cases: nil stadiums, malformed dates, invalid coordinates
  - Boundary conditions: driving limits, radius boundaries
  - Time zone cases: cross-timezone games, DST transitions

Reorganize test structure under Planning/ directory with proper organization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:14:40 -06:00

303 lines
12 KiB
Swift

//
// ItineraryBuilderTests.swift
// SportsTimeTests
//
// Phase 8: ItineraryBuilder Tests
// Builds day-by-day itinerary from route with travel segments.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ItineraryBuilder Tests")
struct ItineraryBuilderTests {
// MARK: - Test Constants
private let defaultConstraints = DrivingConstraints.default
private let calendar = Calendar.current
// Known locations for testing
private let nyc = CLLocationCoordinate2D(latitude: 40.7128, longitude: -73.9352)
private let boston = CLLocationCoordinate2D(latitude: 42.3601, longitude: -71.0589)
private let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
private let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
// MARK: - 8.1 Single Game Creates Single Day
@Test("Single stop creates itinerary with one stop and no travel segments")
func test_builder_SingleGame_CreatesSingleDay() {
// Arrange
let gameId = UUID()
let stop = makeItineraryStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: [gameId]
)
// Act
let result = ItineraryBuilder.build(
stops: [stop],
constraints: defaultConstraints
)
// Assert
#expect(result != nil, "Single stop should produce a valid itinerary")
if let itinerary = result {
#expect(itinerary.stops.count == 1, "Should have exactly 1 stop")
#expect(itinerary.travelSegments.isEmpty, "Should have no travel segments")
#expect(itinerary.totalDrivingHours == 0, "Should have 0 driving hours")
#expect(itinerary.totalDistanceMiles == 0, "Should have 0 distance")
}
}
// MARK: - 8.2 Multi-City Creates Travel Segments Between
@Test("Multiple cities creates travel segments between consecutive stops")
func test_builder_MultiCity_CreatesTravelSegmentsBetween() {
// Arrange
let stops = [
makeItineraryStop(city: "Boston", state: "MA", coordinate: boston, games: [UUID()]),
makeItineraryStop(city: "New York", state: "NY", coordinate: nyc, games: [UUID()]),
makeItineraryStop(city: "Chicago", state: "IL", coordinate: chicago, games: [UUID()])
]
// Act
let result = ItineraryBuilder.build(
stops: stops,
constraints: defaultConstraints
)
// Assert
#expect(result != nil, "Multi-city trip should produce a valid itinerary")
if let itinerary = result {
#expect(itinerary.stops.count == 3, "Should have 3 stops")
#expect(itinerary.travelSegments.count == 2, "Should have 2 travel segments (stops - 1)")
// Verify segment 1: Boston -> NYC
let segment1 = itinerary.travelSegments[0]
#expect(segment1.fromLocation.name == "Boston", "First segment should start from Boston")
#expect(segment1.toLocation.name == "New York", "First segment should end at New York")
#expect(segment1.travelMode == .drive, "Travel mode should be drive")
#expect(segment1.distanceMeters > 0, "Distance should be positive")
#expect(segment1.durationSeconds > 0, "Duration should be positive")
// Verify segment 2: NYC -> Chicago
let segment2 = itinerary.travelSegments[1]
#expect(segment2.fromLocation.name == "New York", "Second segment should start from New York")
#expect(segment2.toLocation.name == "Chicago", "Second segment should end at Chicago")
// Verify totals are accumulated
#expect(itinerary.totalDrivingHours > 0, "Total driving hours should be positive")
#expect(itinerary.totalDistanceMiles > 0, "Total distance should be positive")
}
}
// MARK: - 8.3 Same City Multiple Games Groups On Same Day
@Test("Same city multiple stops have zero distance travel between them")
func test_builder_SameCity_MultipleGames_GroupsOnSameDay() {
// Arrange - Two stops in the same city (different games, same location)
let stops = [
makeItineraryStop(city: "New York", state: "NY", coordinate: nyc, games: [UUID()]),
makeItineraryStop(city: "New York", state: "NY", coordinate: nyc, games: [UUID()])
]
// Act
let result = ItineraryBuilder.build(
stops: stops,
constraints: defaultConstraints
)
// Assert
#expect(result != nil, "Same city stops should produce a valid itinerary")
if let itinerary = result {
#expect(itinerary.stops.count == 2, "Should have 2 stops")
#expect(itinerary.travelSegments.count == 1, "Should have 1 travel segment")
// Travel within same city should be minimal/zero distance
let segment = itinerary.travelSegments[0]
#expect(segment.estimatedDistanceMiles < 1.0,
"Same city travel should have near-zero distance, got \(segment.estimatedDistanceMiles)")
#expect(segment.estimatedDrivingHours < 0.1,
"Same city travel should have near-zero duration, got \(segment.estimatedDrivingHours)")
// Total driving should be minimal
#expect(itinerary.totalDrivingHours < 0.1,
"Total driving hours should be near zero for same city")
}
}
// MARK: - 8.4 Travel Days Inserted When Driving Exceeds 8 Hours
@Test("Multi-day driving is calculated correctly when exceeding 8 hours per day")
func test_builder_TravelDays_InsertedWhenDrivingExceeds8Hours() {
// Arrange - Create a trip that requires multi-day driving
// Boston to Chicago is ~850 miles haversine, ~1100 with road factor
// At 60 mph, that's ~18 hours = 3 travel days (ceil(18/8) = 3)
let stops = [
makeItineraryStop(city: "Boston", state: "MA", coordinate: boston, games: [UUID()]),
makeItineraryStop(city: "Chicago", state: "IL", coordinate: chicago, games: [UUID()])
]
// Use constraints that allow long trips
let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
// Act
let result = ItineraryBuilder.build(
stops: stops,
constraints: constraints
)
// Assert
#expect(result != nil, "Long-distance trip should produce a valid itinerary")
if let itinerary = result {
// Get the travel segment
#expect(itinerary.travelSegments.count == 1, "Should have 1 travel segment")
let segment = itinerary.travelSegments[0]
let drivingHours = segment.estimatedDrivingHours
// Verify this is a multi-day drive
#expect(drivingHours > 8.0, "Boston to Chicago should require more than 8 hours driving")
// Calculate travel days using TravelEstimator
let travelDays = TravelEstimator.calculateTravelDays(
departure: Date(),
drivingHours: drivingHours
)
// Should span multiple days (ceil(hours/8))
let expectedDays = Int(ceil(drivingHours / 8.0))
#expect(travelDays.count == expectedDays,
"Travel should span \(expectedDays) days for \(drivingHours) hours driving, got \(travelDays.count)")
}
}
// MARK: - 8.5 Arrival Time Before Game Calculated
@Test("Segment validator rejects trips where arrival is after game start")
func test_builder_ArrivalTimeBeforeGame_Calculated() {
// Arrange - Create stops where travel time makes arriving on time impossible
let now = Date()
let gameStartSoon = now.addingTimeInterval(2 * 3600) // Game starts in 2 hours
let fromStop = makeItineraryStop(
city: "Boston",
state: "MA",
coordinate: boston,
games: [UUID()],
departureDate: now
)
// NYC game starts in 2 hours, but travel is ~4 hours
let toStop = makeItineraryStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: [UUID()],
firstGameStart: gameStartSoon
)
// Use the arrival validator
let arrivalValidator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
// Act
let result = ItineraryBuilder.build(
stops: [fromStop, toStop],
constraints: defaultConstraints,
segmentValidator: arrivalValidator
)
// Assert - Should return nil because we can't arrive 1 hour before game
// Boston to NYC is ~4 hours, game starts in 2 hours, need 1 hour buffer
// 4 hours travel > 2 hours - 1 hour buffer = 1 hour available
#expect(result == nil, "Should return nil when arrival would be after game start minus buffer")
}
@Test("Segment validator accepts trips where arrival is before game start")
func test_builder_ArrivalTimeBeforeGame_Succeeds() {
// Arrange - Create stops where there's plenty of time
let now = Date()
let gameLater = now.addingTimeInterval(10 * 3600) // Game in 10 hours
let fromStop = makeItineraryStop(
city: "Boston",
state: "MA",
coordinate: boston,
games: [UUID()],
departureDate: now
)
let toStop = makeItineraryStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: [UUID()],
firstGameStart: gameLater
)
let arrivalValidator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
// Act
let result = ItineraryBuilder.build(
stops: [fromStop, toStop],
constraints: defaultConstraints,
segmentValidator: arrivalValidator
)
// Assert - Should succeed, 4 hours travel leaves 5 hours before 10-hour deadline
#expect(result != nil, "Should return valid itinerary when arrival is well before game")
}
// MARK: - 8.6 Empty Route Returns Empty Itinerary
@Test("Empty stops array returns empty itinerary")
func test_builder_EmptyRoute_ReturnsEmptyItinerary() {
// Act
let result = ItineraryBuilder.build(
stops: [],
constraints: defaultConstraints
)
// Assert
#expect(result != nil, "Empty stops should still return a valid (empty) itinerary")
if let itinerary = result {
#expect(itinerary.stops.isEmpty, "Should have no stops")
#expect(itinerary.travelSegments.isEmpty, "Should have no travel segments")
#expect(itinerary.totalDrivingHours == 0, "Should have 0 driving hours")
#expect(itinerary.totalDistanceMiles == 0, "Should have 0 distance")
}
}
// MARK: - Helpers
private func makeItineraryStop(
city: String,
state: String,
coordinate: CLLocationCoordinate2D? = nil,
games: [UUID] = [],
arrivalDate: Date = Date(),
departureDate: Date? = nil,
firstGameStart: Date? = nil
) -> ItineraryStop {
ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: games,
arrivalDate: arrivalDate,
departureDate: departureDate ?? calendar.date(byAdding: .day, value: 1, to: arrivalDate)!,
location: LocationInput(name: city, coordinate: coordinate),
firstGameStart: firstGameStart
)
}
}