refactor(tests): TDD rewrite of all unit tests with spec documentation

Complete rewrite of unit test suite using TDD methodology:

Planning Engine Tests:
- GameDAGRouterTests: Beam search, anchor games, transitions
- ItineraryBuilderTests: Stop connection, validators, EV enrichment
- RouteFiltersTests: Region, time window, scoring filters
- ScenarioA/B/C/D PlannerTests: All planning scenarios
- TravelEstimatorTests: Distance, duration, travel days
- TripPlanningEngineTests: Orchestration, caching, preferences

Domain Model Tests:
- AchievementDefinitionsTests, AnySportTests, DivisionTests
- GameTests, ProgressTests, RegionTests, StadiumTests
- TeamTests, TravelSegmentTests, TripTests, TripPollTests
- TripPreferencesTests, TripStopTests, SportTests

Service Tests:
- FreeScoreAPITests, RouteDescriptionGeneratorTests
- SuggestedTripsGeneratorTests

Export Tests:
- ShareableContentTests (card types, themes, dimensions)

Bug fixes discovered through TDD:
- ShareCardDimensions: mapSnapshotSize exceeded available width (960x480)
- ScenarioBPlanner: Added anchor game validation filter

All tests include:
- Specification tests (expected behavior)
- Invariant tests (properties that must always hold)
- Edge case tests (boundary conditions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-16 14:07:41 -06:00
parent 035dd6f5de
commit 8162b4a029
102 changed files with 13409 additions and 9883 deletions

View File

@@ -2,300 +2,367 @@
// ItineraryBuilderTests.swift
// SportsTimeTests
//
// Phase 8: ItineraryBuilder Tests
// Builds day-by-day itinerary from route with travel segments.
// TDD specification + property tests for ItineraryBuilder.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ItineraryBuilder Tests")
@Suite("ItineraryBuilder")
struct ItineraryBuilderTests {
// MARK: - Test Constants
// MARK: - Test Data
private let constraints = DrivingConstraints.default // 1 driver, 8 hrs/day
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let seattleCoord = CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3316)
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: - Specification Tests: build()
// MARK: - 8.1 Single Game Creates Single Day
@Test("build: empty stops returns empty itinerary")
func build_emptyStops_returnsEmptyItinerary() {
let result = ItineraryBuilder.build(stops: [], constraints: constraints)
@Test("Single stop creates itinerary with one stop and no travel segments")
func test_builder_SingleGame_CreatesSingleDay() {
// Arrange
let gameId = "game_test_\(UUID().uuidString)"
let stop = makeItineraryStop(
#expect(result != nil)
#expect(result?.stops.isEmpty == true)
#expect(result?.travelSegments.isEmpty == true)
#expect(result?.totalDrivingHours == 0)
#expect(result?.totalDistanceMiles == 0)
}
@Test("build: single stop returns single-stop itinerary")
func build_singleStop_returnsSingleStopItinerary() {
let stop = makeStop(city: "New York", coordinate: nycCoord)
let result = ItineraryBuilder.build(stops: [stop], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 1)
#expect(result?.travelSegments.isEmpty == true)
#expect(result?.totalDrivingHours == 0)
#expect(result?.totalDistanceMiles == 0)
}
@Test("build: two stops creates one segment")
func build_twoStops_createsOneSegment() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 2)
#expect(result?.travelSegments.count == 1)
#expect(result?.totalDrivingHours ?? 0 > 0)
#expect(result?.totalDistanceMiles ?? 0 > 0)
}
@Test("build: three stops creates two segments")
func build_threeStops_createsTwoSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)
#expect(result != nil)
#expect(result?.stops.count == 3)
#expect(result?.travelSegments.count == 2)
}
@Test("build: totalDrivingHours is sum of segments")
func build_totalDrivingHours_isSumOfSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)!
let segmentHours = result.travelSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours }
#expect(abs(result.totalDrivingHours - segmentHours) < 0.01)
}
@Test("build: totalDistanceMiles is sum of segments")
func build_totalDistanceMiles_isSumOfSegments() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Philadelphia", coordinate: phillyCoord)
let stop3 = makeStop(city: "Boston", coordinate: bostonCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2, stop3], constraints: constraints)!
let segmentMiles = result.travelSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles }
#expect(abs(result.totalDistanceMiles - segmentMiles) < 0.01)
}
@Test("build: infeasible segment returns nil")
func build_infeasibleSegment_returnsNil() {
// NYC to Seattle is ~2850 miles, ~47 hours driving
// With 1 driver at 8 hrs/day, max is 40 hours (5 days)
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Seattle", coordinate: seattleCoord)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
#expect(result == nil)
}
@Test("build: feasible with more drivers succeeds")
func build_feasibleWithMoreDrivers_succeeds() {
// NYC to Seattle with 2 drivers: max is 80 hours (2*8*5)
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Seattle", coordinate: seattleCoord)
let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: twoDrivers)
#expect(result != nil)
}
// MARK: - Specification Tests: Custom Validator
@Test("build: validator returning true allows segment")
func build_validatorReturnsTrue_allowsSegment() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let alwaysValid: ItineraryBuilder.SegmentValidator = { _, _, _ in true }
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: alwaysValid
)
#expect(result != nil)
}
@Test("build: validator returning false rejects itinerary")
func build_validatorReturnsFalse_rejectsItinerary() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
let alwaysInvalid: ItineraryBuilder.SegmentValidator = { _, _, _ in false }
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: alwaysInvalid
)
#expect(result == nil)
}
@Test("build: validator receives correct stops")
func build_validatorReceivesCorrectStops() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord)
var capturedFromCity: String?
var capturedToCity: String?
let captureValidator: ItineraryBuilder.SegmentValidator = { _, fromStop, toStop in
capturedFromCity = fromStop.city
capturedToCity = toStop.city
return true
}
_ = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: captureValidator
)
#expect(capturedFromCity == "New York")
#expect(capturedToCity == "Boston")
}
// MARK: - Specification Tests: arrivalBeforeGameStart Validator
@Test("arrivalBeforeGameStart: no game start time always passes")
func arrivalBeforeGameStart_noGameStart_alwaysPasses() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "Boston", coordinate: bostonCoord, firstGameStart: nil)
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
#expect(result != nil)
}
@Test("arrivalBeforeGameStart: sufficient time passes")
func arrivalBeforeGameStart_sufficientTime_passes() {
let now = Date()
let tomorrow = calendar.date(byAdding: .day, value: 1, to: now)!
let gameTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: tomorrow)!
let stop1 = makeStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: [gameId]
coordinate: nycCoord,
departureDate: now
)
let stop2 = makeStop(
city: "Boston",
coordinate: bostonCoord,
firstGameStart: gameTime
)
// Act
// NYC to Boston is ~4 hours, game is tomorrow at 7pm, plenty of time
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop],
constraints: defaultConstraints
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
// Assert
#expect(result != nil, "Single stop should produce a valid itinerary")
#expect(result != nil)
}
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")
@Test("arrivalBeforeGameStart: insufficient time fails")
func arrivalBeforeGameStart_insufficientTime_fails() {
let now = Date()
let gameTime = now.addingTimeInterval(2 * 3600) // Game in 2 hours
let stop1 = makeStop(
city: "New York",
coordinate: nycCoord,
departureDate: now
)
let stop2 = makeStop(
city: "Chicago", // ~13 hours away
coordinate: chicagoCoord,
firstGameStart: gameTime
)
let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600)
let result = ItineraryBuilder.build(
stops: [stop1, stop2],
constraints: constraints,
segmentValidator: validator
)
// Should fail because we can't get to Chicago in 2 hours
// (assuming the segment is even feasible, which it isn't for 1 driver)
// Either the segment is infeasible OR the validator rejects it
#expect(result == nil)
}
// MARK: - Property Tests
@Test("Property: segments count equals stops count minus one")
func property_segmentsCountEqualsStopsMinusOne() {
for count in [2, 3, 4, 5] {
let stops = (0..<count).map { i in
makeStop(city: "City\(i)", coordinate: nycCoord) // Same coord for all = always feasible
}
if let result = ItineraryBuilder.build(stops: stops, constraints: constraints) {
#expect(result.travelSegments.count == stops.count - 1,
"For \(count) stops, should have \(count - 1) segments")
}
}
}
// MARK: - 8.2 Multi-City Creates Travel Segments Between
@Test("Multiple cities creates travel segments between consecutive stops")
func test_builder_MultiCity_CreatesTravelSegmentsBetween() {
// Arrange
@Test("Property: totals are non-negative")
func property_totalsNonNegative() {
let stops = [
makeItineraryStop(city: "Boston", state: "MA", coordinate: boston, games: ["game_boston_\(UUID().uuidString)"]),
makeItineraryStop(city: "New York", state: "NY", coordinate: nyc, games: ["game_nyc_\(UUID().uuidString)"]),
makeItineraryStop(city: "Chicago", state: "IL", coordinate: chicago, games: ["game_chicago_\(UUID().uuidString)"])
makeStop(city: "New York", coordinate: nycCoord),
makeStop(city: "Philadelphia", coordinate: phillyCoord)
]
// 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")
if let result = ItineraryBuilder.build(stops: stops, constraints: constraints) {
#expect(result.totalDrivingHours >= 0)
#expect(result.totalDistanceMiles >= 0)
}
}
// MARK: - 8.3 Same City Multiple Games Groups On Same Day
@Test("Property: empty/single stop always succeeds")
func property_emptyOrSingleStopAlwaysSucceeds() {
let emptyResult = ItineraryBuilder.build(stops: [], constraints: constraints)
#expect(emptyResult != nil)
@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: ["game_nyc_1_\(UUID().uuidString)"]),
makeItineraryStop(city: "New York", state: "NY", coordinate: nyc, games: ["game_nyc_2_\(UUID().uuidString)"])
]
// 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: ["game_boston_\(UUID().uuidString)"]),
makeItineraryStop(city: "Chicago", state: "IL", coordinate: chicago, games: ["game_chicago_\(UUID().uuidString)"])
]
// Use constraints that allow long trips
let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
// Act
let result = ItineraryBuilder.build(
stops: stops,
let singleResult = ItineraryBuilder.build(
stops: [makeStop(city: "NYC", coordinate: nycCoord)],
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)")
}
#expect(singleResult != nil)
}
// MARK: - 8.5 Arrival Time Before Game Calculated
// MARK: - Edge Case Tests
@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
@Test("Edge: stops with nil coordinates use fallback")
func edge_nilCoordinates_useFallback() {
let stop1 = makeStop(city: "City1", coordinate: nil)
let stop2 = makeStop(city: "City2", coordinate: nil)
let fromStop = makeItineraryStop(
city: "Boston",
state: "MA",
coordinate: boston,
games: ["game_boston_\(UUID().uuidString)"],
departureDate: now
)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
// NYC game starts in 2 hours, but travel is ~4 hours
let toStop = makeItineraryStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: ["game_nyc_\(UUID().uuidString)"],
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")
// Should use fallback distance (300 miles)
#expect(result != nil)
#expect(result?.totalDistanceMiles ?? 0 > 0)
}
@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
@Test("Edge: same city stops have zero distance")
func edge_sameCityStops_zeroDistance() {
let stop1 = makeStop(city: "New York", coordinate: nycCoord)
let stop2 = makeStop(city: "New York", coordinate: nycCoord) // Same location
let fromStop = makeItineraryStop(
city: "Boston",
state: "MA",
coordinate: boston,
games: ["game_boston_\(UUID().uuidString)"],
departureDate: now
)
let result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
let toStop = makeItineraryStop(
city: "New York",
state: "NY",
coordinate: nyc,
games: ["game_nyc_\(UUID().uuidString)"],
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")
#expect(result != nil)
// Same coordinates should result in ~0 distance
#expect(result?.totalDistanceMiles ?? 1000 < 1)
}
// MARK: - 8.6 Empty Route Returns Empty Itinerary
@Test("Edge: very long trip is still feasible with multiple drivers")
func edge_veryLongTrip_feasibleWithMultipleDrivers() {
// NYC -> Chicago -> Seattle
let stops = [
makeStop(city: "New York", coordinate: nycCoord),
makeStop(city: "Chicago", coordinate: chicagoCoord),
makeStop(city: "Seattle", coordinate: seattleCoord)
]
@Test("Empty stops array returns empty itinerary")
func test_builder_EmptyRoute_ReturnsEmptyItinerary() {
// Act
let result = ItineraryBuilder.build(
stops: [],
constraints: defaultConstraints
)
// With 3 drivers, max is 120 hours (3*8*5)
let threeDrivers = DrivingConstraints(numberOfDrivers: 3, maxHoursPerDriverPerDay: 8.0)
// Assert
#expect(result != nil, "Empty stops should still return a valid (empty) itinerary")
let result = ItineraryBuilder.build(stops: stops, constraints: threeDrivers)
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")
}
#expect(result != nil)
}
// MARK: - Helpers
// MARK: - Helper Methods
private func makeItineraryStop(
private func makeStop(
city: String,
state: String,
coordinate: CLLocationCoordinate2D? = nil,
games: [String] = [],
arrivalDate: Date = Date(),
departureDate: Date? = nil,
coordinate: CLLocationCoordinate2D?,
departureDate: Date = Date(),
firstGameStart: Date? = nil
) -> ItineraryStop {
ItineraryStop(
city: city,
state: state,
state: "XX",
coordinate: coordinate,
games: games,
arrivalDate: arrivalDate,
departureDate: departureDate ?? calendar.date(byAdding: .day, value: 1, to: arrivalDate)!,
location: LocationInput(name: city, coordinate: coordinate),
games: [],
arrivalDate: departureDate,
departureDate: departureDate,
location: LocationInput(
name: city,
coordinate: coordinate
),
firstGameStart: firstGameStart
)
}