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>
370 lines
13 KiB
Swift
370 lines
13 KiB
Swift
//
|
|
// ItineraryBuilderTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// TDD specification + property tests for ItineraryBuilder.
|
|
//
|
|
|
|
import Testing
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
@Suite("ItineraryBuilder")
|
|
struct ItineraryBuilderTests {
|
|
|
|
// 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 calendar = Calendar.current
|
|
|
|
// MARK: - Specification Tests: build()
|
|
|
|
@Test("build: empty stops returns empty itinerary")
|
|
func build_emptyStops_returnsEmptyItinerary() {
|
|
let result = ItineraryBuilder.build(stops: [], constraints: constraints)
|
|
|
|
#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",
|
|
coordinate: nycCoord,
|
|
departureDate: now
|
|
)
|
|
let stop2 = makeStop(
|
|
city: "Boston",
|
|
coordinate: bostonCoord,
|
|
firstGameStart: gameTime
|
|
)
|
|
|
|
// 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: [stop1, stop2],
|
|
constraints: constraints,
|
|
segmentValidator: validator
|
|
)
|
|
|
|
#expect(result != nil)
|
|
}
|
|
|
|
@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")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("Property: totals are non-negative")
|
|
func property_totalsNonNegative() {
|
|
let stops = [
|
|
makeStop(city: "New York", coordinate: nycCoord),
|
|
makeStop(city: "Philadelphia", coordinate: phillyCoord)
|
|
]
|
|
|
|
if let result = ItineraryBuilder.build(stops: stops, constraints: constraints) {
|
|
#expect(result.totalDrivingHours >= 0)
|
|
#expect(result.totalDistanceMiles >= 0)
|
|
}
|
|
}
|
|
|
|
@Test("Property: empty/single stop always succeeds")
|
|
func property_emptyOrSingleStopAlwaysSucceeds() {
|
|
let emptyResult = ItineraryBuilder.build(stops: [], constraints: constraints)
|
|
#expect(emptyResult != nil)
|
|
|
|
let singleResult = ItineraryBuilder.build(
|
|
stops: [makeStop(city: "NYC", coordinate: nycCoord)],
|
|
constraints: constraints
|
|
)
|
|
#expect(singleResult != nil)
|
|
}
|
|
|
|
// MARK: - Edge Case Tests
|
|
|
|
@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 result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
|
|
|
|
// Should use fallback distance (300 miles)
|
|
#expect(result != nil)
|
|
#expect(result?.totalDistanceMiles ?? 0 > 0)
|
|
}
|
|
|
|
@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 result = ItineraryBuilder.build(stops: [stop1, stop2], constraints: constraints)
|
|
|
|
#expect(result != nil)
|
|
// Same coordinates should result in ~0 distance
|
|
#expect(result?.totalDistanceMiles ?? 1000 < 1)
|
|
}
|
|
|
|
@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)
|
|
]
|
|
|
|
// With 3 drivers, max is 120 hours (3*8*5)
|
|
let threeDrivers = DrivingConstraints(numberOfDrivers: 3, maxHoursPerDriverPerDay: 8.0)
|
|
|
|
let result = ItineraryBuilder.build(stops: stops, constraints: threeDrivers)
|
|
|
|
#expect(result != nil)
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
private func makeStop(
|
|
city: String,
|
|
coordinate: CLLocationCoordinate2D?,
|
|
departureDate: Date = Date(),
|
|
firstGameStart: Date? = nil
|
|
) -> ItineraryStop {
|
|
ItineraryStop(
|
|
city: city,
|
|
state: "XX",
|
|
coordinate: coordinate,
|
|
games: [],
|
|
arrivalDate: departureDate,
|
|
departureDate: departureDate,
|
|
location: LocationInput(
|
|
name: city,
|
|
coordinate: coordinate
|
|
),
|
|
firstGameStart: firstGameStart
|
|
)
|
|
}
|
|
}
|