// // 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.. ItineraryOption { let gameIds = (0.. 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) } } }