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:
255
SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift
Normal file
255
SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift
Normal file
@@ -0,0 +1,255 @@
|
||||
//
|
||||
// SuggestedTripsGeneratorTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// TDD specification tests for SuggestedTripsGenerator types.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import SportsTime
|
||||
|
||||
// MARK: - SuggestedTrip Tests
|
||||
|
||||
@Suite("SuggestedTrip")
|
||||
struct SuggestedTripTests {
|
||||
|
||||
// MARK: - Test Data
|
||||
|
||||
private func makeTrip() -> Trip {
|
||||
Trip(
|
||||
name: "Test Trip",
|
||||
preferences: TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
sports: [.mlb],
|
||||
startDate: Date(),
|
||||
endDate: Date().addingTimeInterval(86400 * 7),
|
||||
leisureLevel: .moderate
|
||||
),
|
||||
stops: [],
|
||||
travelSegments: [],
|
||||
totalGames: 3,
|
||||
totalDistanceMeters: 1000,
|
||||
totalDrivingSeconds: 3600
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSuggestedTrip(
|
||||
region: Region = .east,
|
||||
isSingleSport: Bool = true,
|
||||
sports: Set<Sport> = [.mlb]
|
||||
) -> SuggestedTrip {
|
||||
SuggestedTrip(
|
||||
id: UUID(),
|
||||
region: region,
|
||||
isSingleSport: isSingleSport,
|
||||
trip: makeTrip(),
|
||||
richGames: [:],
|
||||
sports: sports
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Specification Tests: displaySports
|
||||
|
||||
/// - Expected Behavior: Returns sorted array of sports
|
||||
@Test("displaySports: returns sorted sports array")
|
||||
func displaySports_sorted() {
|
||||
let suggested = makeSuggestedTrip(sports: [.nhl, .mlb, .nba])
|
||||
let display = suggested.displaySports
|
||||
|
||||
#expect(display.count == 3)
|
||||
// Sports should be sorted by rawValue
|
||||
let sortedExpected = [Sport.mlb, .nba, .nhl].sorted { $0.rawValue < $1.rawValue }
|
||||
#expect(display == sortedExpected)
|
||||
}
|
||||
|
||||
@Test("displaySports: single sport returns array of one")
|
||||
func displaySports_singleSport() {
|
||||
let suggested = makeSuggestedTrip(sports: [.mlb])
|
||||
#expect(suggested.displaySports.count == 1)
|
||||
#expect(suggested.displaySports.first == .mlb)
|
||||
}
|
||||
|
||||
@Test("displaySports: empty sports returns empty array")
|
||||
func displaySports_empty() {
|
||||
let suggested = makeSuggestedTrip(sports: [])
|
||||
#expect(suggested.displaySports.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Specification Tests: sportLabel
|
||||
|
||||
/// - Expected Behavior: Single sport returns sport rawValue
|
||||
@Test("sportLabel: returns sport name for single sport")
|
||||
func sportLabel_singleSport() {
|
||||
let suggested = makeSuggestedTrip(sports: [.mlb])
|
||||
#expect(suggested.sportLabel == "MLB")
|
||||
}
|
||||
|
||||
/// - Expected Behavior: Multiple sports returns "Multi-Sport"
|
||||
@Test("sportLabel: returns 'Multi-Sport' for multiple sports")
|
||||
func sportLabel_multipleSports() {
|
||||
let suggested = makeSuggestedTrip(sports: [.mlb, .nba])
|
||||
#expect(suggested.sportLabel == "Multi-Sport")
|
||||
}
|
||||
|
||||
@Test("sportLabel: returns 'Multi-Sport' for three sports")
|
||||
func sportLabel_threeSports() {
|
||||
let suggested = makeSuggestedTrip(sports: [.mlb, .nba, .nhl])
|
||||
#expect(suggested.sportLabel == "Multi-Sport")
|
||||
}
|
||||
|
||||
/// - Expected Behavior: Empty sports returns "Multi-Sport" (no single sport to display)
|
||||
@Test("sportLabel: returns Multi-Sport for no sports")
|
||||
func sportLabel_noSports() {
|
||||
let suggested = makeSuggestedTrip(sports: [])
|
||||
#expect(suggested.sportLabel == "Multi-Sport")
|
||||
}
|
||||
|
||||
// MARK: - Specification Tests: Properties
|
||||
|
||||
@Test("SuggestedTrip: stores region")
|
||||
func suggestedTrip_region() {
|
||||
let suggested = makeSuggestedTrip(region: .west)
|
||||
#expect(suggested.region == .west)
|
||||
}
|
||||
|
||||
@Test("SuggestedTrip: stores isSingleSport")
|
||||
func suggestedTrip_isSingleSport() {
|
||||
let single = makeSuggestedTrip(isSingleSport: true)
|
||||
let multi = makeSuggestedTrip(isSingleSport: false)
|
||||
#expect(single.isSingleSport == true)
|
||||
#expect(multi.isSingleSport == false)
|
||||
}
|
||||
|
||||
// MARK: - Invariant Tests
|
||||
|
||||
/// - Invariant: sports.count == 1 implies sportLabel is sport rawValue (uppercase)
|
||||
@Test("Invariant: single sport implies specific label")
|
||||
func invariant_singleSportImpliesSpecificLabel() {
|
||||
let singleSports: [Sport] = [.mlb, .nba, .nhl, .nfl]
|
||||
for sport in singleSports {
|
||||
let suggested = makeSuggestedTrip(sports: [sport])
|
||||
if suggested.sports.count == 1 {
|
||||
#expect(suggested.sportLabel == sport.rawValue) // rawValue is uppercase (e.g., "MLB")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// - Invariant: sports.count > 1 implies sportLabel is "Multi-Sport"
|
||||
@Test("Invariant: multiple sports implies Multi-Sport label")
|
||||
func invariant_multipleSportsImpliesMultiSportLabel() {
|
||||
let suggested = makeSuggestedTrip(sports: [.mlb, .nba])
|
||||
if suggested.sports.count > 1 {
|
||||
#expect(suggested.sportLabel == "Multi-Sport")
|
||||
}
|
||||
}
|
||||
|
||||
/// - Invariant: displaySports.count == sports.count
|
||||
@Test("Invariant: displaySports count matches sports count")
|
||||
func invariant_displaySportsCountMatchesSportsCount() {
|
||||
let testCases: [Set<Sport>] = [
|
||||
[],
|
||||
[.mlb],
|
||||
[.mlb, .nba],
|
||||
[.mlb, .nba, .nhl]
|
||||
]
|
||||
|
||||
for sports in testCases {
|
||||
let suggested = makeSuggestedTrip(sports: sports)
|
||||
#expect(suggested.displaySports.count == sports.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haversine Distance Tests
|
||||
|
||||
@Suite("Haversine Distance")
|
||||
struct HaversineDistanceTests {
|
||||
|
||||
// Note: haversineDistance is a private static function in SuggestedTripsGenerator
|
||||
// These tests document the expected behavior for distance calculations
|
||||
|
||||
// MARK: - Specification Tests: Known Distances
|
||||
|
||||
/// - Expected Behavior: Distance between same points is 0
|
||||
@Test("Distance: same point returns 0")
|
||||
func distance_samePoint() {
|
||||
// New York to New York
|
||||
let distance = calculateHaversine(
|
||||
lat1: 40.7128, lon1: -74.0060,
|
||||
lat2: 40.7128, lon2: -74.0060
|
||||
)
|
||||
#expect(distance == 0)
|
||||
}
|
||||
|
||||
/// - Expected Behavior: NYC to LA is approximately 2,450 miles
|
||||
@Test("Distance: NYC to LA approximately 2450 miles")
|
||||
func distance_nycToLa() {
|
||||
// New York: 40.7128, -74.0060
|
||||
// Los Angeles: 34.0522, -118.2437
|
||||
let distance = calculateHaversine(
|
||||
lat1: 40.7128, lon1: -74.0060,
|
||||
lat2: 34.0522, lon2: -118.2437
|
||||
)
|
||||
// Allow 5% tolerance
|
||||
#expect(distance > 2300 && distance < 2600)
|
||||
}
|
||||
|
||||
/// - Expected Behavior: NYC to Boston is approximately 190 miles
|
||||
@Test("Distance: NYC to Boston approximately 190 miles")
|
||||
func distance_nycToBoston() {
|
||||
// New York: 40.7128, -74.0060
|
||||
// Boston: 42.3601, -71.0589
|
||||
let distance = calculateHaversine(
|
||||
lat1: 40.7128, lon1: -74.0060,
|
||||
lat2: 42.3601, lon2: -71.0589
|
||||
)
|
||||
// Allow 10% tolerance
|
||||
#expect(distance > 170 && distance < 220)
|
||||
}
|
||||
|
||||
// MARK: - Invariant Tests
|
||||
|
||||
/// - Invariant: Distance is symmetric (A to B == B to A)
|
||||
@Test("Invariant: distance is symmetric")
|
||||
func invariant_symmetric() {
|
||||
let distanceAB = calculateHaversine(
|
||||
lat1: 40.7128, lon1: -74.0060,
|
||||
lat2: 34.0522, lon2: -118.2437
|
||||
)
|
||||
let distanceBA = calculateHaversine(
|
||||
lat1: 34.0522, lon1: -118.2437,
|
||||
lat2: 40.7128, lon2: -74.0060
|
||||
)
|
||||
#expect(abs(distanceAB - distanceBA) < 0.001)
|
||||
}
|
||||
|
||||
/// - Invariant: Distance is always non-negative
|
||||
@Test("Invariant: distance is non-negative")
|
||||
func invariant_nonNegative() {
|
||||
let testCases: [(lat1: Double, lon1: Double, lat2: Double, lon2: Double)] = [
|
||||
(0, 0, 0, 0),
|
||||
(40.0, -74.0, 34.0, -118.0),
|
||||
(-33.9, 151.2, 51.5, -0.1), // Sydney to London
|
||||
(90, 0, -90, 0) // North to South pole
|
||||
]
|
||||
|
||||
for (lat1, lon1, lat2, lon2) in testCases {
|
||||
let distance = calculateHaversine(lat1: lat1, lon1: lon1, lat2: lat2, lon2: lon2)
|
||||
#expect(distance >= 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helper (mirrors implementation)
|
||||
|
||||
private func calculateHaversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let R = 3959.0 // Earth radius in miles
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
let dLon = (lon2 - lon1) * .pi / 180
|
||||
let a = sin(dLat/2) * sin(dLat/2) +
|
||||
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||
sin(dLon/2) * sin(dLon/2)
|
||||
let c = 2 * atan2(sqrt(a), sqrt(1-a))
|
||||
return R * c
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user