Files
Sportstime/SportsTimeTests/Planning/PlanningModelsTests.swift
Trey t 8162b4a029 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>
2026-01-16 14:07:41 -06:00

488 lines
18 KiB
Swift

//
// PlanningModelsTests.swift
// SportsTimeTests
//
// TDD specification + property tests for PlanningModels.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("PlanningModels")
struct PlanningModelsTests {
// MARK: - DrivingConstraints Tests
@Suite("DrivingConstraints")
struct DrivingConstraintsTests {
@Test("default has 1 driver and 8 hours per day")
func defaultConstraints() {
let constraints = DrivingConstraints.default
#expect(constraints.numberOfDrivers == 1)
#expect(constraints.maxHoursPerDriverPerDay == 8.0)
#expect(constraints.maxDailyDrivingHours == 8.0)
}
@Test("maxDailyDrivingHours equals drivers times hours")
func maxDailyDrivingHours_calculation() {
let twoDrivers = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
#expect(twoDrivers.maxDailyDrivingHours == 16.0)
let threeLongDrivers = DrivingConstraints(numberOfDrivers: 3, maxHoursPerDriverPerDay: 10.0)
#expect(threeLongDrivers.maxDailyDrivingHours == 30.0)
}
@Test("numberOfDrivers clamped to minimum 1")
func numberOfDrivers_clampedToOne() {
let zeroDrivers = DrivingConstraints(numberOfDrivers: 0, maxHoursPerDriverPerDay: 8.0)
#expect(zeroDrivers.numberOfDrivers == 1)
let negativeDrivers = DrivingConstraints(numberOfDrivers: -5, maxHoursPerDriverPerDay: 8.0)
#expect(negativeDrivers.numberOfDrivers == 1)
}
@Test("maxHoursPerDriverPerDay clamped to minimum 1.0")
func maxHoursPerDay_clampedToOne() {
let zeroHours = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0)
#expect(zeroHours.maxHoursPerDriverPerDay == 1.0)
let negativeHours = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: -5)
#expect(negativeHours.maxHoursPerDriverPerDay == 1.0)
}
@Test("init from preferences extracts values correctly")
func initFromPreferences() {
let prefs = TripPreferences(
numberOfDrivers: 3,
maxDrivingHoursPerDriver: 6.0
)
let constraints = DrivingConstraints(from: prefs)
#expect(constraints.numberOfDrivers == 3)
#expect(constraints.maxHoursPerDriverPerDay == 6.0)
#expect(constraints.maxDailyDrivingHours == 18.0)
}
@Test("init from preferences defaults to 8 hours when nil")
func initFromPreferences_nilHoursDefaultsTo8() {
let prefs = TripPreferences(
numberOfDrivers: 2,
maxDrivingHoursPerDriver: nil
)
let constraints = DrivingConstraints(from: prefs)
#expect(constraints.maxHoursPerDriverPerDay == 8.0)
}
// Property tests
@Test("Property: maxDailyDrivingHours always >= 1.0")
func property_maxDailyHoursAlwaysPositive() {
for drivers in [-10, 0, 1, 5, 100] {
for hours in [-10.0, 0.0, 0.5, 1.0, 8.0, 24.0] {
let constraints = DrivingConstraints(
numberOfDrivers: drivers,
maxHoursPerDriverPerDay: hours
)
#expect(constraints.maxDailyDrivingHours >= 1.0)
}
}
}
}
// MARK: - ItineraryOption Tests
@Suite("ItineraryOption")
struct ItineraryOptionTests {
// MARK: - isValid Tests
@Test("isValid: single stop with no travel segments is valid")
func isValid_singleStop_noSegments_valid() {
let option = makeOption(stopsCount: 1, segmentsCount: 0)
#expect(option.isValid)
}
@Test("isValid: two stops with one segment is valid")
func isValid_twoStops_oneSegment_valid() {
let option = makeOption(stopsCount: 2, segmentsCount: 1)
#expect(option.isValid)
}
@Test("isValid: three stops with two segments is valid")
func isValid_threeStops_twoSegments_valid() {
let option = makeOption(stopsCount: 3, segmentsCount: 2)
#expect(option.isValid)
}
@Test("isValid: mismatched stops and segments is invalid")
func isValid_mismatchedCounts_invalid() {
let tooFewSegments = makeOption(stopsCount: 3, segmentsCount: 1)
#expect(!tooFewSegments.isValid)
let tooManySegments = makeOption(stopsCount: 2, segmentsCount: 3)
#expect(!tooManySegments.isValid)
}
@Test("isValid: zero stops with zero segments is valid")
func isValid_zeroStops_valid() {
let option = makeOption(stopsCount: 0, segmentsCount: 0)
#expect(option.isValid)
}
// MARK: - totalGames Tests
@Test("totalGames: sums games across all stops")
func totalGames_sumsAcrossStops() {
let option = ItineraryOption(
rank: 1,
stops: [
makeStop(games: ["g1", "g2"]),
makeStop(games: ["g3"]),
makeStop(games: [])
],
travelSegments: [],
totalDrivingHours: 0,
totalDistanceMiles: 0,
geographicRationale: ""
)
#expect(option.totalGames == 3)
}
@Test("totalGames: empty stops returns zero")
func totalGames_emptyStops_returnsZero() {
let option = makeOption(stopsCount: 0, segmentsCount: 0)
#expect(option.totalGames == 0)
}
// MARK: - sortByLeisure Tests
@Test("sortByLeisure: empty options returns empty")
func sortByLeisure_empty_returnsEmpty() {
let result = ItineraryOption.sortByLeisure([], leisureLevel: .packed)
#expect(result.isEmpty)
}
@Test("sortByLeisure: packed prefers most games")
func sortByLeisure_packed_prefersMoreGames() {
let fewerGames = makeOptionWithGamesAndHours(games: 2, hours: 5)
let moreGames = makeOptionWithGamesAndHours(games: 5, hours: 10)
let result = ItineraryOption.sortByLeisure([fewerGames, moreGames], leisureLevel: .packed)
#expect(result.first?.totalGames == 5)
}
@Test("sortByLeisure: packed with same games prefers less driving")
func sortByLeisure_packed_sameGames_prefersLessDriving() {
let moreDriving = makeOptionWithGamesAndHours(games: 5, hours: 10)
let lessDriving = makeOptionWithGamesAndHours(games: 5, hours: 5)
let result = ItineraryOption.sortByLeisure([moreDriving, lessDriving], leisureLevel: .packed)
#expect(result.first?.totalDrivingHours == 5)
}
@Test("sortByLeisure: relaxed prefers less driving")
func sortByLeisure_relaxed_prefersLessDriving() {
let moreDriving = makeOptionWithGamesAndHours(games: 5, hours: 10)
let lessDriving = makeOptionWithGamesAndHours(games: 2, hours: 3)
let result = ItineraryOption.sortByLeisure([moreDriving, lessDriving], leisureLevel: .relaxed)
#expect(result.first?.totalDrivingHours == 3)
}
@Test("sortByLeisure: relaxed with same driving prefers fewer games")
func sortByLeisure_relaxed_sameDriving_prefersFewerGames() {
let moreGames = makeOptionWithGamesAndHours(games: 5, hours: 10)
let fewerGames = makeOptionWithGamesAndHours(games: 2, hours: 10)
let result = ItineraryOption.sortByLeisure([moreGames, fewerGames], leisureLevel: .relaxed)
#expect(result.first?.totalGames == 2)
}
@Test("sortByLeisure: moderate prefers best efficiency")
func sortByLeisure_moderate_prefersBestEfficiency() {
// 5 games / 10 hours = 0.5 efficiency
let lowEfficiency = makeOptionWithGamesAndHours(games: 5, hours: 10)
// 4 games / 4 hours = 1.0 efficiency
let highEfficiency = makeOptionWithGamesAndHours(games: 4, hours: 4)
let result = ItineraryOption.sortByLeisure([lowEfficiency, highEfficiency], leisureLevel: .moderate)
// High efficiency should come first
#expect(result.first?.totalGames == 4)
}
@Test("sortByLeisure: reassigns ranks sequentially")
func sortByLeisure_reassignsRanks() {
let options = [
makeOptionWithGamesAndHours(games: 1, hours: 1),
makeOptionWithGamesAndHours(games: 3, hours: 3),
makeOptionWithGamesAndHours(games: 2, hours: 2)
]
let result = ItineraryOption.sortByLeisure(options, leisureLevel: .packed)
#expect(result[0].rank == 1)
#expect(result[1].rank == 2)
#expect(result[2].rank == 3)
}
@Test("sortByLeisure: all options are returned")
func sortByLeisure_allOptionsReturned() {
let options = [
makeOptionWithGamesAndHours(games: 1, hours: 1),
makeOptionWithGamesAndHours(games: 2, hours: 2),
makeOptionWithGamesAndHours(games: 3, hours: 3)
]
for leisure in [LeisureLevel.packed, .moderate, .relaxed] {
let result = ItineraryOption.sortByLeisure(options, leisureLevel: leisure)
#expect(result.count == options.count, "All options should be returned for \(leisure)")
}
}
// Property tests
@Test("Property: sortByLeisure output count equals input count")
func property_sortByLeisure_preservesCount() {
let options = (0..<10).map { _ in
makeOptionWithGamesAndHours(games: Int.random(in: 1...10), hours: Double.random(in: 1...20))
}
for leisure in [LeisureLevel.packed, .moderate, .relaxed] {
let result = ItineraryOption.sortByLeisure(options, leisureLevel: leisure)
#expect(result.count == options.count)
}
}
@Test("Property: sortByLeisure ranks are sequential starting at 1")
func property_sortByLeisure_sequentialRanks() {
let options = (0..<5).map { _ in
makeOptionWithGamesAndHours(games: Int.random(in: 1...10), hours: Double.random(in: 1...20))
}
let result = ItineraryOption.sortByLeisure(options, leisureLevel: .moderate)
for (index, option) in result.enumerated() {
#expect(option.rank == index + 1, "Rank should be \(index + 1), got \(option.rank)")
}
}
// Helper methods
private func makeOption(stopsCount: Int, segmentsCount: Int) -> ItineraryOption {
ItineraryOption(
rank: 1,
stops: (0..<stopsCount).map { _ in makeStop(games: []) },
travelSegments: (0..<segmentsCount).map { _ in makeSegment() },
totalDrivingHours: 0,
totalDistanceMiles: 0,
geographicRationale: ""
)
}
private func makeOptionWithGamesAndHours(games: Int, hours: Double) -> ItineraryOption {
let gameIds = (0..<games).map { "game\($0)" }
return ItineraryOption(
rank: 1,
stops: [makeStop(games: gameIds)],
travelSegments: [],
totalDrivingHours: hours,
totalDistanceMiles: hours * 60, // 60 mph average
geographicRationale: ""
)
}
private func makeStop(games: [String]) -> ItineraryStop {
ItineraryStop(
city: "TestCity",
state: "XX",
coordinate: nil,
games: games,
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "Test", coordinate: nil),
firstGameStart: nil
)
}
private func makeSegment() -> TravelSegment {
TravelSegment(
fromLocation: LocationInput(name: "A", coordinate: nil),
toLocation: LocationInput(name: "B", coordinate: nil),
travelMode: .drive,
distanceMeters: 10000,
durationSeconds: 3600
)
}
}
// MARK: - ItineraryStop Tests
@Suite("ItineraryStop")
struct ItineraryStopTests {
@Test("hasGames: true when games array is not empty")
func hasGames_notEmpty_true() {
let stop = makeStop(games: ["game1"])
#expect(stop.hasGames)
}
@Test("hasGames: false when games array is empty")
func hasGames_empty_false() {
let stop = makeStop(games: [])
#expect(!stop.hasGames)
}
@Test("equality based on id only")
func equality_basedOnId() {
let stop1 = ItineraryStop(
city: "New York",
state: "NY",
coordinate: nil,
games: ["g1"],
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "NY", coordinate: nil),
firstGameStart: nil
)
// Same id via same instance
#expect(stop1 == stop1)
}
private func makeStop(games: [String]) -> ItineraryStop {
ItineraryStop(
city: "TestCity",
state: "XX",
coordinate: nil,
games: games,
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "Test", coordinate: nil),
firstGameStart: nil
)
}
}
// MARK: - ItineraryResult Tests
@Suite("ItineraryResult")
struct ItineraryResultTests {
@Test("isSuccess: true for success case")
func isSuccess_success_true() {
let result = ItineraryResult.success([])
#expect(result.isSuccess)
}
@Test("isSuccess: false for failure case")
func isSuccess_failure_false() {
let result = ItineraryResult.failure(PlanningFailure(reason: .noGamesInRange))
#expect(!result.isSuccess)
}
@Test("options: returns options for success")
func options_success_returnsOptions() {
let option = makeSimpleOption()
let result = ItineraryResult.success([option])
#expect(result.options.count == 1)
}
@Test("options: returns empty for failure")
func options_failure_returnsEmpty() {
let result = ItineraryResult.failure(PlanningFailure(reason: .noGamesInRange))
#expect(result.options.isEmpty)
}
@Test("failure: returns failure for failure case")
func failure_failure_returnsFailure() {
let result = ItineraryResult.failure(PlanningFailure(reason: .noValidRoutes))
#expect(result.failure?.reason == .noValidRoutes)
}
@Test("failure: returns nil for success")
func failure_success_returnsNil() {
let result = ItineraryResult.success([])
#expect(result.failure == nil)
}
private func makeSimpleOption() -> ItineraryOption {
ItineraryOption(
rank: 1,
stops: [],
travelSegments: [],
totalDrivingHours: 0,
totalDistanceMiles: 0,
geographicRationale: ""
)
}
}
// MARK: - PlanningFailure Tests
@Suite("PlanningFailure")
struct PlanningFailureTests {
@Test("message: noGamesInRange")
func message_noGamesInRange() {
let failure = PlanningFailure(reason: .noGamesInRange)
#expect(failure.message.contains("No games found"))
}
@Test("message: noValidRoutes")
func message_noValidRoutes() {
let failure = PlanningFailure(reason: .noValidRoutes)
#expect(failure.message.contains("No valid routes"))
}
@Test("message: repeatCityViolation includes cities")
func message_repeatCityViolation_includesCities() {
let failure = PlanningFailure(reason: .repeatCityViolation(cities: ["Boston", "Chicago"]))
#expect(failure.message.contains("Boston"))
#expect(failure.message.contains("Chicago"))
}
@Test("message: repeatCityViolation truncates long list")
func message_repeatCityViolation_truncates() {
let cities = ["A", "B", "C", "D", "E"]
let failure = PlanningFailure(reason: .repeatCityViolation(cities: cities))
// Should show first 3 and "and 2 more"
#expect(failure.message.contains("and 2 more"))
}
@Test("FailureReason equality")
func failureReason_equality() {
#expect(PlanningFailure.FailureReason.noGamesInRange == .noGamesInRange)
#expect(PlanningFailure.FailureReason.noValidRoutes != .noGamesInRange)
#expect(PlanningFailure.FailureReason.repeatCityViolation(cities: ["A"]) == .repeatCityViolation(cities: ["A"]))
#expect(PlanningFailure.FailureReason.repeatCityViolation(cities: ["A"]) != .repeatCityViolation(cities: ["B"]))
}
}
// MARK: - MustStopConfig Tests
@Suite("MustStopConfig")
struct MustStopConfigTests {
@Test("default proximity is 25 miles")
func defaultProximity() {
let config = MustStopConfig()
#expect(config.proximityMiles == 25)
}
@Test("custom proximity preserved")
func customProximity() {
let config = MustStopConfig(proximityMiles: 50)
#expect(config.proximityMiles == 50)
}
}
}