Files
Sportstime/SportsTimeTests/Domain/TripTests.swift
2026-02-18 13:00:15 -06:00

417 lines
13 KiB
Swift

//
// TripTests.swift
// SportsTimeTests
//
// TDD specification tests for Trip model.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("Trip")
struct TripTests {
// MARK: - Test Data
private var calendar: Calendar { TestClock.calendar }
private func makePreferences() -> TripPreferences {
TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: TestClock.now,
endDate: TestClock.now.addingTimeInterval(86400 * 7)
)
}
private func makeStop(
city: String,
arrivalDate: Date,
departureDate: Date,
games: [String] = []
) -> TripStop {
TripStop(
stopNumber: 1,
city: city,
state: "XX",
arrivalDate: arrivalDate,
departureDate: departureDate,
games: games
)
}
// MARK: - Specification Tests: itineraryDays
@Test("itineraryDays: returns one day per calendar day")
func itineraryDays_oneDayPerCalendarDay() {
let start = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let end = calendar.date(from: DateComponents(year: 2026, month: 6, day: 18))!
let stop = makeStop(city: "NYC", arrivalDate: start, departureDate: end)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
let days = trip.itineraryDays()
// 15th, 16th, 17th (18th is departure day, not an activity day)
#expect(days.count == 3)
}
@Test("itineraryDays: dayNumber starts at 1")
func itineraryDays_dayNumberStartsAtOne() {
let start = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let end = calendar.date(from: DateComponents(year: 2026, month: 6, day: 17))!
let stop = makeStop(city: "NYC", arrivalDate: start, departureDate: end)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
let days = trip.itineraryDays()
#expect(days.first?.dayNumber == 1)
}
@Test("itineraryDays: dayNumber increments correctly")
func itineraryDays_dayNumberIncrements() {
let start = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let end = calendar.date(from: DateComponents(year: 2026, month: 6, day: 20))!
let stop = makeStop(city: "NYC", arrivalDate: start, departureDate: end)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
let days = trip.itineraryDays()
for (index, day) in days.enumerated() {
#expect(day.dayNumber == index + 1)
}
}
@Test("itineraryDays: empty for trip with no stops")
func itineraryDays_emptyForNoStops() {
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: []
)
let days = trip.itineraryDays()
#expect(days.isEmpty)
}
// MARK: - Specification Tests: tripDuration
@Test("tripDuration: minimum is 1 day")
func tripDuration_minimumIsOne() {
let date = TestClock.now
let stop = makeStop(city: "NYC", arrivalDate: date, departureDate: date)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
#expect(trip.tripDuration >= 1)
}
@Test("tripDuration: calculates days between first arrival and last departure")
func tripDuration_calculatesCorrectly() {
let start = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let end = calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))!
let stop = makeStop(city: "NYC", arrivalDate: start, departureDate: end)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
#expect(trip.tripDuration == 8) // 15th through 22nd = 8 days
}
@Test("tripDuration: is 0 for trip with no stops")
func tripDuration_zeroForNoStops() {
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: []
)
#expect(trip.tripDuration == 0)
}
// MARK: - Specification Tests: cities
@Test("cities: returns deduplicated list preserving order")
func cities_deduplicatedPreservingOrder() {
let date = TestClock.now
let stop1 = makeStop(city: "NYC", arrivalDate: date, departureDate: date)
let stop2 = makeStop(city: "Boston", arrivalDate: date, departureDate: date)
let stop3 = makeStop(city: "NYC", arrivalDate: date, departureDate: date)
let stop4 = makeStop(city: "Chicago", arrivalDate: date, departureDate: date)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop1, stop2, stop3, stop4]
)
#expect(trip.cities == ["NYC", "Boston", "Chicago"])
}
@Test("cities: empty for trip with no stops")
func cities_emptyForNoStops() {
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: []
)
#expect(trip.cities.isEmpty)
}
// MARK: - Specification Tests: displayName
@Test("displayName: uses arrow separator between cities")
func displayName_arrowSeparator() {
let date = TestClock.now
let stop1 = makeStop(city: "NYC", arrivalDate: date, departureDate: date)
let stop2 = makeStop(city: "Boston", arrivalDate: date, departureDate: date)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop1, stop2]
)
#expect(trip.displayName == "NYC → Boston")
}
@Test("displayName: uses trip name when no cities")
func displayName_fallsBackToName() {
let trip = Trip(
name: "My Trip",
preferences: makePreferences(),
stops: []
)
#expect(trip.displayName == "My Trip")
}
// MARK: - Specification Tests: Unit Conversions
@Test("totalDistanceMiles: converts meters to miles")
func totalDistanceMiles_conversion() {
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
totalDistanceMeters: 160934.4 // ~100 miles
)
#expect(abs(trip.totalDistanceMiles - 100.0) < 0.01)
}
@Test("totalDrivingHours: converts seconds to hours")
func totalDrivingHours_conversion() {
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
totalDrivingSeconds: 7200 // 2 hours
)
#expect(trip.totalDrivingHours == 2.0)
}
// MARK: - Invariant Tests
@Test("Invariant: tripDuration >= 1 when stops exist")
func invariant_tripDurationMinimum() {
let testDates: [(start: DateComponents, end: DateComponents)] = [
(DateComponents(year: 2026, month: 6, day: 15), DateComponents(year: 2026, month: 6, day: 15)),
(DateComponents(year: 2026, month: 6, day: 15), DateComponents(year: 2026, month: 6, day: 16)),
(DateComponents(year: 2026, month: 6, day: 15), DateComponents(year: 2026, month: 6, day: 22)),
]
for (start, end) in testDates {
let startDate = calendar.date(from: start)!
let endDate = calendar.date(from: end)!
let stop = makeStop(city: "NYC", arrivalDate: startDate, departureDate: endDate)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
#expect(trip.tripDuration >= 1)
}
}
@Test("Invariant: cities has no duplicates")
func invariant_citiesNoDuplicates() {
let date = TestClock.now
// Create stops with duplicate cities
let stops = [
makeStop(city: "A", arrivalDate: date, departureDate: date),
makeStop(city: "B", arrivalDate: date, departureDate: date),
makeStop(city: "A", arrivalDate: date, departureDate: date),
makeStop(city: "C", arrivalDate: date, departureDate: date),
makeStop(city: "B", arrivalDate: date, departureDate: date),
]
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: stops
)
let cities = trip.cities
let uniqueCities = Set(cities)
#expect(cities.count == uniqueCities.count, "cities should not have duplicates")
}
@Test("Invariant: itineraryDays dayNumber starts at 1 and increments")
func invariant_dayNumberSequence() {
let start = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let end = calendar.date(from: DateComponents(year: 2026, month: 6, day: 20))!
let stop = makeStop(city: "NYC", arrivalDate: start, departureDate: end)
let trip = Trip(
name: "Test Trip",
preferences: makePreferences(),
stops: [stop]
)
let days = trip.itineraryDays()
guard !days.isEmpty else { return }
#expect(days.first?.dayNumber == 1)
for i in 1..<days.count {
#expect(days[i].dayNumber == days[i - 1].dayNumber + 1)
}
}
}
// MARK: - TripScore Tests
@Suite("TripScore")
struct TripScoreTests {
// MARK: - Specification Tests: scoreGrade
@Test("scoreGrade: 90-100 returns A+")
func scoreGrade_APlus() {
let score90 = TripScore(overallScore: 90, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
let score100 = TripScore(overallScore: 100, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score90.scoreGrade == "A+")
#expect(score100.scoreGrade == "A+")
}
@Test("scoreGrade: 85-89.99 returns A")
func scoreGrade_A() {
let score = TripScore(overallScore: 87, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "A")
}
@Test("scoreGrade: 80-84.99 returns A-")
func scoreGrade_AMinus() {
let score = TripScore(overallScore: 82, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "A-")
}
@Test("scoreGrade: 75-79.99 returns B+")
func scoreGrade_BPlus() {
let score = TripScore(overallScore: 77, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "B+")
}
@Test("scoreGrade: 70-74.99 returns B")
func scoreGrade_B() {
let score = TripScore(overallScore: 72, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "B")
}
@Test("scoreGrade: 65-69.99 returns B-")
func scoreGrade_BMinus() {
let score = TripScore(overallScore: 67, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "B-")
}
@Test("scoreGrade: 60-64.99 returns C+")
func scoreGrade_CPlus() {
let score = TripScore(overallScore: 62, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "C+")
}
@Test("scoreGrade: 55-59.99 returns C")
func scoreGrade_C() {
let score = TripScore(overallScore: 57, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "C")
}
@Test("scoreGrade: below 55 returns C-")
func scoreGrade_CMinus() {
let score = TripScore(overallScore: 50, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == "C-")
}
@Test("scoreGrade: boundary values")
func scoreGrade_boundaries() {
// Test exact boundary values
let testCases: [(score: Double, expected: String)] = [
(90.0, "A+"),
(89.9999, "A"),
(85.0, "A"),
(84.9999, "A-"),
(80.0, "A-"),
(79.9999, "B+"),
(75.0, "B+"),
(74.9999, "B"),
(70.0, "B"),
(69.9999, "B-"),
(65.0, "B-"),
(64.9999, "C+"),
(60.0, "C+"),
(59.9999, "C"),
(55.0, "C"),
(54.9999, "C-"),
]
for (scoreValue, expected) in testCases {
let score = TripScore(overallScore: scoreValue, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.scoreGrade == expected, "Score \(scoreValue) should be \(expected)")
}
}
// MARK: - Property Tests
@Test("Property: formattedOverallScore rounds to integer")
func property_formattedOverallScore() {
let score = TripScore(overallScore: 85.7, gameQualityScore: 0, routeEfficiencyScore: 0, leisureBalanceScore: 0, preferenceAlignmentScore: 0)
#expect(score.formattedOverallScore == "86")
}
}