Adds messy/realistic data factories to TestFixtures, new PlannerOutputSanityTests, and updates all scenario planner tests with improved coverage and assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
293 lines
10 KiB
Swift
293 lines
10 KiB
Swift
//
|
|
// TripPlanningEngineTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// TDD specification tests for TripPlanningEngine.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
@Suite("TripPlanningEngine")
|
|
struct TripPlanningEngineTests {
|
|
|
|
// MARK: - Test Data
|
|
|
|
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
|
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
|
|
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
|
|
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
|
|
|
|
// MARK: - Specification Tests: Driving Constraints
|
|
|
|
@Test("DrivingConstraints: calculates maxDailyDrivingHours correctly")
|
|
func drivingConstraints_maxDailyHours() {
|
|
let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 6.0)
|
|
#expect(constraints.maxDailyDrivingHours == 12.0)
|
|
}
|
|
|
|
@Test("DrivingConstraints: clamps negative drivers to 1")
|
|
func drivingConstraints_clampsNegativeDrivers() {
|
|
let constraints = DrivingConstraints(numberOfDrivers: -5, maxHoursPerDriverPerDay: 8.0)
|
|
#expect(constraints.numberOfDrivers == 1)
|
|
#expect(constraints.maxDailyDrivingHours >= 1.0)
|
|
}
|
|
|
|
@Test("DrivingConstraints: clamps zero hours to minimum")
|
|
func drivingConstraints_clampsZeroHours() {
|
|
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0)
|
|
#expect(constraints.maxHoursPerDriverPerDay == 1.0)
|
|
}
|
|
|
|
// MARK: - Specification Tests: Trip Preferences Computed Properties
|
|
|
|
@Test("totalDriverHoursPerDay: defaults to 8 hours when nil")
|
|
func totalDriverHoursPerDay_default() {
|
|
let prefs = TripPreferences(
|
|
numberOfDrivers: 1,
|
|
maxDrivingHoursPerDriver: nil
|
|
)
|
|
#expect(prefs.totalDriverHoursPerDay == 8.0)
|
|
}
|
|
|
|
@Test("totalDriverHoursPerDay: multiplies by number of drivers")
|
|
func totalDriverHoursPerDay_multipleDrivers() {
|
|
let prefs = TripPreferences(
|
|
numberOfDrivers: 2,
|
|
maxDrivingHoursPerDriver: 6.0
|
|
)
|
|
#expect(prefs.totalDriverHoursPerDay == 12.0)
|
|
}
|
|
|
|
@Test("effectiveTripDuration: uses explicit tripDuration when set")
|
|
func effectiveTripDuration_explicit() {
|
|
let prefs = TripPreferences(
|
|
tripDuration: 5
|
|
)
|
|
#expect(prefs.effectiveTripDuration == 5)
|
|
}
|
|
|
|
@Test("effectiveTripDuration: calculates from date range when tripDuration is nil")
|
|
func effectiveTripDuration_calculated() {
|
|
let calendar = TestClock.calendar
|
|
let startDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
|
|
let endDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))!
|
|
|
|
let prefs = TripPreferences(
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
tripDuration: nil
|
|
)
|
|
#expect(prefs.effectiveTripDuration == 8) // 8 days inclusive (15th through 22nd)
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: totalDriverHoursPerDay > 0")
|
|
func invariant_totalDriverHoursPositive() {
|
|
let prefs1 = TripPreferences(numberOfDrivers: 1)
|
|
#expect(prefs1.totalDriverHoursPerDay > 0)
|
|
|
|
let prefs2 = TripPreferences(numberOfDrivers: 3, maxDrivingHoursPerDriver: 4)
|
|
#expect(prefs2.totalDriverHoursPerDay > 0)
|
|
}
|
|
|
|
@Test("Invariant: effectiveTripDuration >= 1")
|
|
func invariant_effectiveTripDurationMinimum() {
|
|
let testCases: [Int?] = [nil, 1, 5, 10]
|
|
|
|
for duration in testCases {
|
|
let prefs = TripPreferences(tripDuration: duration)
|
|
#expect(prefs.effectiveTripDuration >= 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Travel Segment Validation
|
|
|
|
@Test("planTrip: multi-stop result always has travel segments")
|
|
func planTrip_multiStopResult_alwaysHasTravelSegments() {
|
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
|
let day2 = TestClock.calendar.date(byAdding: .day, value: 1, to: baseDate)!
|
|
let day3 = TestClock.calendar.date(byAdding: .day, value: 2, to: baseDate)!
|
|
|
|
let game1 = TestFixtures.game(city: "New York", dateTime: baseDate)
|
|
let game2 = TestFixtures.game(city: "Boston", dateTime: day2)
|
|
let game3 = TestFixtures.game(city: "Philadelphia", dateTime: day3)
|
|
|
|
let stadiums = TestFixtures.stadiumMap(for: [game1, game2, game3])
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: baseDate,
|
|
endDate: day3
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [game1, game2, game3],
|
|
teams: [:],
|
|
stadiums: stadiums
|
|
)
|
|
|
|
let engine = TripPlanningEngine()
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
|
for option in options {
|
|
#expect(option.isValid, "Every returned option must be valid (segments = stops - 1)")
|
|
if option.stops.count > 1 {
|
|
#expect(option.travelSegments.count == option.stops.count - 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("planTrip: N stops always have exactly N-1 travel segments")
|
|
func planTrip_nStops_haveExactlyNMinus1Segments() {
|
|
let baseDate = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 19, minute: 0)
|
|
|
|
// Create 5 games across cities to produce routes of varying lengths
|
|
let cities = ["New York", "Boston", "Philadelphia", "Chicago", "Detroit"]
|
|
var games: [Game] = []
|
|
for (i, city) in cities.enumerated() {
|
|
let date = TestClock.calendar.date(byAdding: .day, value: i, to: baseDate)!
|
|
games.append(TestFixtures.game(city: city, dateTime: date))
|
|
}
|
|
|
|
let stadiums = TestFixtures.stadiumMap(for: games)
|
|
let endDate = TestClock.calendar.date(byAdding: .day, value: cities.count, to: baseDate)!
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: baseDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: games,
|
|
teams: [:],
|
|
stadiums: stadiums
|
|
)
|
|
|
|
let engine = TripPlanningEngine()
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)"); return }
|
|
#expect(!options.isEmpty, "Should produce at least one option")
|
|
for option in options {
|
|
if option.stops.count > 1 {
|
|
#expect(option.travelSegments.count == option.stops.count - 1,
|
|
"Option with \(option.stops.count) stops must have exactly \(option.stops.count - 1) segments, got \(option.travelSegments.count)")
|
|
} else {
|
|
#expect(option.travelSegments.isEmpty,
|
|
"Single-stop option must have 0 segments")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("planTrip: invalid options are filtered out")
|
|
func planTrip_invalidOptions_areFilteredOut() {
|
|
// Create a valid ItineraryOption manually with wrong segment count
|
|
let stop1 = ItineraryStop(
|
|
city: "New York", state: "NY",
|
|
coordinate: nycCoord,
|
|
games: ["g1"], arrivalDate: Date(), departureDate: Date(),
|
|
location: LocationInput(name: "New York", coordinate: nycCoord),
|
|
firstGameStart: Date()
|
|
)
|
|
let stop2 = ItineraryStop(
|
|
city: "Boston", state: "MA",
|
|
coordinate: bostonCoord,
|
|
games: ["g2"], arrivalDate: Date(), departureDate: Date(),
|
|
location: LocationInput(name: "Boston", coordinate: bostonCoord),
|
|
firstGameStart: Date()
|
|
)
|
|
|
|
// Invalid: 2 stops but 0 segments
|
|
let invalidOption = ItineraryOption(
|
|
rank: 1, stops: [stop1, stop2],
|
|
travelSegments: [],
|
|
totalDrivingHours: 0, totalDistanceMiles: 0,
|
|
geographicRationale: "test"
|
|
)
|
|
#expect(!invalidOption.isValid, "2 stops with 0 segments should be invalid")
|
|
|
|
// Valid: 2 stops with 1 segment
|
|
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
|
|
let validOption = ItineraryOption(
|
|
rank: 1, stops: [stop1, stop2],
|
|
travelSegments: [segment],
|
|
totalDrivingHours: 3.5, totalDistanceMiles: 215,
|
|
geographicRationale: "test"
|
|
)
|
|
#expect(validOption.isValid, "2 stops with 1 segment should be valid")
|
|
}
|
|
|
|
@Test("planTrip: inverted date range returns failure")
|
|
func planTrip_invertedDateRange_returnsFailure() {
|
|
let endDate = TestFixtures.date(year: 2026, month: 6, day: 1)
|
|
let startDate = TestFixtures.date(year: 2026, month: 6, day: 10)
|
|
|
|
let prefs = TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
let request = PlanningRequest(
|
|
preferences: prefs,
|
|
availableGames: [],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
let engine = TripPlanningEngine()
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
#expect(!result.isSuccess)
|
|
if let failure = result.failure {
|
|
#expect(failure.reason == .missingDateRange)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
private func makeStadium(
|
|
id: String,
|
|
city: String,
|
|
coordinate: CLLocationCoordinate2D
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: "\(city) Stadium",
|
|
city: city,
|
|
state: "XX",
|
|
latitude: coordinate.latitude,
|
|
longitude: coordinate.longitude,
|
|
capacity: 40000,
|
|
sport: .mlb
|
|
)
|
|
}
|
|
|
|
private func makeGame(
|
|
id: String,
|
|
stadiumId: String,
|
|
dateTime: Date
|
|
) -> Game {
|
|
Game(
|
|
id: id,
|
|
homeTeamId: "team1",
|
|
awayTeamId: "team2",
|
|
stadiumId: stadiumId,
|
|
dateTime: dateTime,
|
|
sport: .mlb,
|
|
season: "2026"
|
|
)
|
|
}
|
|
}
|