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>
417 lines
13 KiB
Swift
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 { Calendar.current }
|
|
|
|
private func makePreferences() -> TripPreferences {
|
|
TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: Date(),
|
|
endDate: Date().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 = Date()
|
|
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 = Date()
|
|
|
|
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 = Date()
|
|
|
|
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 = Date()
|
|
|
|
// 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")
|
|
}
|
|
}
|