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:
487
SportsTimeTests/Planning/PlanningModelsTests.swift
Normal file
487
SportsTimeTests/Planning/PlanningModelsTests.swift
Normal file
@@ -0,0 +1,487 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user