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:
Trey t
2026-01-16 14:07:41 -06:00
parent 035dd6f5de
commit 8162b4a029
102 changed files with 13409 additions and 9883 deletions

View File

@@ -0,0 +1,364 @@
//
// AchievementDefinitionsTests.swift
// SportsTimeTests
//
// TDD specification tests for AchievementRegistry and related models.
//
import Testing
import SwiftUI
@testable import SportsTime
@Suite("AchievementCategory")
struct AchievementCategoryTests {
@Test("Property: all cases have displayName")
func property_allHaveDisplayName() {
for category in AchievementCategory.allCases {
#expect(!category.displayName.isEmpty)
}
}
@Test("displayName: count returns 'Milestones'")
func displayName_count() {
#expect(AchievementCategory.count.displayName == "Milestones")
}
@Test("displayName: division returns 'Divisions'")
func displayName_division() {
#expect(AchievementCategory.division.displayName == "Divisions")
}
}
// MARK: - AchievementDefinition Tests
@Suite("AchievementDefinition")
struct AchievementDefinitionTests {
@Test("equality: based on id only")
func equality_basedOnId() {
let def1 = AchievementDefinition(
id: "test_achievement",
name: "Name A",
description: "Description A",
category: .count,
iconName: "icon.a",
iconColor: .blue,
requirement: .visitCount(5)
)
let def2 = AchievementDefinition(
id: "test_achievement",
name: "Different Name",
description: "Different Description",
category: .division,
iconName: "icon.b",
iconColor: .red,
requirement: .visitCount(10)
)
#expect(def1 == def2, "Achievements with same id should be equal")
}
@Test("inequality: different ids")
func inequality_differentIds() {
let def1 = AchievementDefinition(
id: "achievement_1",
name: "Same Name",
description: "Same Description",
category: .count,
iconName: "icon",
iconColor: .blue,
requirement: .visitCount(5)
)
let def2 = AchievementDefinition(
id: "achievement_2",
name: "Same Name",
description: "Same Description",
category: .count,
iconName: "icon",
iconColor: .blue,
requirement: .visitCount(5)
)
#expect(def1 != def2, "Achievements with different ids should not be equal")
}
@Test("Property: divisionId can be set")
func property_divisionId() {
let def = AchievementDefinition(
id: "test_div_ach",
name: "Division Achievement",
description: "Complete a division",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_al_east"),
divisionId: "mlb_al_east"
)
#expect(def.divisionId == "mlb_al_east")
}
@Test("Property: conferenceId can be set")
func property_conferenceId() {
let def = AchievementDefinition(
id: "test_conf_ach",
name: "Conference Achievement",
description: "Complete a conference",
category: .conference,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeConference("mlb_al"),
conferenceId: "mlb_al"
)
#expect(def.conferenceId == "mlb_al")
}
}
// MARK: - AchievementRegistry Tests
@Suite("AchievementRegistry")
struct AchievementRegistryTests {
// MARK: - Specification Tests: all
@Test("all: is sorted by sortOrder ascending")
func all_sortedBySortOrder() {
let achievements = AchievementRegistry.all
for i in 1..<achievements.count {
#expect(
achievements[i].sortOrder >= achievements[i - 1].sortOrder,
"Achievement at index \(i) should have sortOrder >= previous"
)
}
}
@Test("all: contains count achievements")
func all_containsCountAchievements() {
let countAchievements = AchievementRegistry.all.filter { $0.category == .count }
#expect(countAchievements.count >= 5, "Should have multiple count achievements")
}
@Test("all: contains division achievements")
func all_containsDivisionAchievements() {
let divisionAchievements = AchievementRegistry.all.filter { $0.category == .division }
#expect(divisionAchievements.count >= 16, "Should have division achievements for MLB, NBA, NHL")
}
// MARK: - Specification Tests: achievement(byId:)
@Test("achievement(byId:): finds existing achievement")
func achievementById_found() {
let achievement = AchievementRegistry.achievement(byId: "first_visit")
#expect(achievement != nil)
#expect(achievement?.name == "First Pitch")
}
@Test("achievement(byId:): returns nil for unknown ID")
func achievementById_notFound() {
let achievement = AchievementRegistry.achievement(byId: "nonexistent_achievement")
#expect(achievement == nil)
}
// MARK: - Specification Tests: achievements(forCategory:)
@Test("achievements(forCategory:): filters by exact category")
func achievementsForCategory_filters() {
let countAchievements = AchievementRegistry.achievements(forCategory: .count)
for achievement in countAchievements {
#expect(achievement.category == .count)
}
}
@Test("achievements(forCategory:): returns all division achievements")
func achievementsForCategory_division() {
let divisionAchievements = AchievementRegistry.achievements(forCategory: .division)
#expect(!divisionAchievements.isEmpty)
for achievement in divisionAchievements {
#expect(achievement.category == .division)
}
}
// MARK: - Specification Tests: achievements(forSport:)
@Test("achievements(forSport:): includes sport-specific achievements")
func achievementsForSport_includesSportSpecific() {
let mlbAchievements = AchievementRegistry.achievements(forSport: .mlb)
let sportSpecific = mlbAchievements.filter { $0.sport == .mlb }
#expect(!sportSpecific.isEmpty, "Should include MLB-specific achievements")
}
@Test("achievements(forSport:): includes cross-sport achievements")
func achievementsForSport_includesCrossSport() {
let mlbAchievements = AchievementRegistry.achievements(forSport: .mlb)
let crossSport = mlbAchievements.filter { $0.sport == nil }
#expect(!crossSport.isEmpty, "Should include cross-sport achievements (sport == nil)")
}
// MARK: - Specification Tests: divisionAchievements(forSport:)
@Test("divisionAchievements(forSport:): filters to division category AND sport")
func divisionAchievementsForSport_filters() {
let mlbDivisionAchievements = AchievementRegistry.divisionAchievements(forSport: .mlb)
#expect(mlbDivisionAchievements.count == 6, "MLB has 6 divisions")
for achievement in mlbDivisionAchievements {
#expect(achievement.category == .division)
#expect(achievement.sport == .mlb)
}
}
@Test("divisionAchievements(forSport:): NBA has 6 divisions")
func divisionAchievementsForSport_nba() {
let nbaDivisionAchievements = AchievementRegistry.divisionAchievements(forSport: .nba)
#expect(nbaDivisionAchievements.count == 6)
}
@Test("divisionAchievements(forSport:): NHL has 4 divisions")
func divisionAchievementsForSport_nhl() {
let nhlDivisionAchievements = AchievementRegistry.divisionAchievements(forSport: .nhl)
#expect(nhlDivisionAchievements.count == 4)
}
// MARK: - Specification Tests: conferenceAchievements(forSport:)
@Test("conferenceAchievements(forSport:): filters to conference category AND sport")
func conferenceAchievementsForSport_filters() {
let mlbConferenceAchievements = AchievementRegistry.conferenceAchievements(forSport: .mlb)
#expect(mlbConferenceAchievements.count == 2, "MLB has 2 leagues (AL, NL)")
for achievement in mlbConferenceAchievements {
#expect(achievement.category == .conference)
#expect(achievement.sport == .mlb)
}
}
// MARK: - Invariant Tests
@Test("Invariant: all achievement IDs are unique")
func invariant_uniqueIds() {
let ids = AchievementRegistry.all.map { $0.id }
let uniqueIds = Set(ids)
#expect(ids.count == uniqueIds.count, "All achievement IDs should be unique")
}
@Test("Invariant: division achievements have non-nil divisionId")
func invariant_divisionAchievementsHaveDivisionId() {
let divisionAchievements = AchievementRegistry.achievements(forCategory: .division)
for achievement in divisionAchievements {
#expect(
achievement.divisionId != nil,
"Division achievement \(achievement.id) should have divisionId"
)
}
}
@Test("Invariant: conference achievements have non-nil conferenceId")
func invariant_conferenceAchievementsHaveConferenceId() {
let conferenceAchievements = AchievementRegistry.achievements(forCategory: .conference)
for achievement in conferenceAchievements {
#expect(
achievement.conferenceId != nil,
"Conference achievement \(achievement.id) should have conferenceId"
)
}
}
@Test("Invariant: all achievements have non-empty name")
func invariant_allHaveNames() {
for achievement in AchievementRegistry.all {
#expect(!achievement.name.isEmpty, "Achievement \(achievement.id) should have a name")
}
}
@Test("Invariant: all achievements have non-empty description")
func invariant_allHaveDescriptions() {
for achievement in AchievementRegistry.all {
#expect(!achievement.description.isEmpty, "Achievement \(achievement.id) should have a description")
}
}
@Test("Invariant: all achievements have non-empty iconName")
func invariant_allHaveIconNames() {
for achievement in AchievementRegistry.all {
#expect(!achievement.iconName.isEmpty, "Achievement \(achievement.id) should have an iconName")
}
}
}
// MARK: - AchievementRequirement Tests
@Suite("AchievementRequirement")
struct AchievementRequirementTests {
@Test("Property: visitCount requirement stores count")
func visitCount_storesValue() {
let requirement = AchievementRequirement.visitCount(10)
if case .visitCount(let count) = requirement {
#expect(count == 10)
} else {
Issue.record("Should be visitCount case")
}
}
@Test("Property: completeDivision requirement stores division ID")
func completeDivision_storesId() {
let requirement = AchievementRequirement.completeDivision("mlb_al_east")
if case .completeDivision(let divisionId) = requirement {
#expect(divisionId == "mlb_al_east")
} else {
Issue.record("Should be completeDivision case")
}
}
@Test("Property: visitsInDays requirement stores both values")
func visitsInDays_storesValues() {
let requirement = AchievementRequirement.visitsInDays(5, days: 7)
if case .visitsInDays(let visits, let days) = requirement {
#expect(visits == 5)
#expect(days == 7)
} else {
Issue.record("Should be visitsInDays case")
}
}
@Test("hashable: same requirements are equal")
func hashable_equality() {
let req1 = AchievementRequirement.visitCount(10)
let req2 = AchievementRequirement.visitCount(10)
#expect(req1 == req2)
}
@Test("hashable: different requirements are not equal")
func hashable_inequality() {
let req1 = AchievementRequirement.visitCount(10)
let req2 = AchievementRequirement.visitCount(20)
#expect(req1 != req2)
}
}

View File

@@ -0,0 +1,194 @@
//
// AnySportTests.swift
// SportsTimeTests
//
// TDD specification tests for AnySport protocol default implementations.
//
import Testing
import Foundation
import SwiftUI
@testable import SportsTime
// MARK: - Mock AnySport for Testing
/// A mock type that conforms to AnySport for testing the default implementation
private struct MockSport: AnySport, Hashable {
let sportId: String
let displayName: String
let iconName: String
let color: SwiftUI.Color
let seasonMonths: (start: Int, end: Int)
var id: String { sportId }
init(
sportId: String = "mock",
displayName: String = "Mock Sport",
iconName: String = "sportscourt",
color: SwiftUI.Color = .blue,
seasonStart: Int,
seasonEnd: Int
) {
self.sportId = sportId
self.displayName = displayName
self.iconName = iconName
self.color = color
self.seasonMonths = (seasonStart, seasonEnd)
}
static func == (lhs: MockSport, rhs: MockSport) -> Bool {
lhs.sportId == rhs.sportId
}
func hash(into hasher: inout Hasher) {
hasher.combine(sportId)
}
}
@Suite("AnySport Protocol")
struct AnySportTests {
// MARK: - Test Data
private var calendar: Calendar { Calendar.current }
private func date(month: Int) -> Date {
calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
}
// MARK: - Specification Tests: isInSeason (Normal Range)
@Test("isInSeason: normal season (start <= end), month in range returns true")
func isInSeason_normalSeason_inRange() {
// Season: April (4) to October (10)
let sport = MockSport(seasonStart: 4, seasonEnd: 10)
#expect(sport.isInSeason(for: date(month: 4)) == true) // April
#expect(sport.isInSeason(for: date(month: 7)) == true) // July
#expect(sport.isInSeason(for: date(month: 10)) == true) // October
}
@Test("isInSeason: normal season, month outside range returns false")
func isInSeason_normalSeason_outOfRange() {
// Season: April (4) to October (10)
let sport = MockSport(seasonStart: 4, seasonEnd: 10)
#expect(sport.isInSeason(for: date(month: 1)) == false) // January
#expect(sport.isInSeason(for: date(month: 3)) == false) // March
#expect(sport.isInSeason(for: date(month: 11)) == false) // November
#expect(sport.isInSeason(for: date(month: 12)) == false) // December
}
@Test("isInSeason: normal season boundary - start month is in season")
func isInSeason_normalSeason_startBoundary() {
let sport = MockSport(seasonStart: 3, seasonEnd: 10)
#expect(sport.isInSeason(for: date(month: 3)) == true)
#expect(sport.isInSeason(for: date(month: 2)) == false)
}
@Test("isInSeason: normal season boundary - end month is in season")
func isInSeason_normalSeason_endBoundary() {
let sport = MockSport(seasonStart: 3, seasonEnd: 10)
#expect(sport.isInSeason(for: date(month: 10)) == true)
#expect(sport.isInSeason(for: date(month: 11)) == false)
}
// MARK: - Specification Tests: isInSeason (Wrap-Around)
@Test("isInSeason: wrap-around season (start > end), month >= start returns true")
func isInSeason_wrapAround_afterStart() {
// Season: October (10) to June (6) - wraps around year
let sport = MockSport(seasonStart: 10, seasonEnd: 6)
#expect(sport.isInSeason(for: date(month: 10)) == true) // October (start)
#expect(sport.isInSeason(for: date(month: 11)) == true) // November
#expect(sport.isInSeason(for: date(month: 12)) == true) // December
}
@Test("isInSeason: wrap-around season, month <= end returns true")
func isInSeason_wrapAround_beforeEnd() {
// Season: October (10) to June (6) - wraps around year
let sport = MockSport(seasonStart: 10, seasonEnd: 6)
#expect(sport.isInSeason(for: date(month: 1)) == true) // January
#expect(sport.isInSeason(for: date(month: 3)) == true) // March
#expect(sport.isInSeason(for: date(month: 6)) == true) // June (end)
}
@Test("isInSeason: wrap-around season, gap months return false")
func isInSeason_wrapAround_gap() {
// Season: October (10) to June (6) - gap is July, August, September
let sport = MockSport(seasonStart: 10, seasonEnd: 6)
#expect(sport.isInSeason(for: date(month: 7)) == false) // July
#expect(sport.isInSeason(for: date(month: 8)) == false) // August
#expect(sport.isInSeason(for: date(month: 9)) == false) // September
}
@Test("isInSeason: wrap-around season boundary - start month is in season")
func isInSeason_wrapAround_startBoundary() {
let sport = MockSport(seasonStart: 10, seasonEnd: 4)
#expect(sport.isInSeason(for: date(month: 10)) == true)
#expect(sport.isInSeason(for: date(month: 9)) == false)
}
@Test("isInSeason: wrap-around season boundary - end month is in season")
func isInSeason_wrapAround_endBoundary() {
let sport = MockSport(seasonStart: 10, seasonEnd: 4)
#expect(sport.isInSeason(for: date(month: 4)) == true)
#expect(sport.isInSeason(for: date(month: 5)) == false)
}
// MARK: - Specification Tests: Edge Cases
@Test("isInSeason: single month season (start == end)")
func isInSeason_singleMonth() {
// Season is only March
let sport = MockSport(seasonStart: 3, seasonEnd: 3)
#expect(sport.isInSeason(for: date(month: 3)) == true)
#expect(sport.isInSeason(for: date(month: 2)) == false)
#expect(sport.isInSeason(for: date(month: 4)) == false)
}
@Test("isInSeason: full year season (January to December)")
func isInSeason_fullYear() {
let sport = MockSport(seasonStart: 1, seasonEnd: 12)
for month in 1...12 {
#expect(sport.isInSeason(for: date(month: month)) == true)
}
}
// MARK: - Invariant Tests
@Test("Invariant: isInSeason returns true for exactly the months in range")
func invariant_exactlyMonthsInRange() {
// Normal season: March to October
let normalSport = MockSport(seasonStart: 3, seasonEnd: 10)
let expectedInSeason = Set(3...10)
for month in 1...12 {
let expected = expectedInSeason.contains(month)
#expect(normalSport.isInSeason(for: date(month: month)) == expected)
}
}
@Test("Invariant: wrap-around isInSeason returns true for months >= start OR <= end")
func invariant_wrapAroundMonths() {
// Wrap-around season: October to April
let wrapSport = MockSport(seasonStart: 10, seasonEnd: 4)
// In season: 10, 11, 12, 1, 2, 3, 4
let expectedInSeason = Set([10, 11, 12, 1, 2, 3, 4])
for month in 1...12 {
let expected = expectedInSeason.contains(month)
#expect(wrapSport.isInSeason(for: date(month: month)) == expected)
}
}
}

View File

@@ -0,0 +1,317 @@
//
// DivisionTests.swift
// SportsTimeTests
//
// TDD specification tests for Division, Conference, and LeagueStructure models.
//
import Testing
@testable import SportsTime
@Suite("Division")
struct DivisionTests {
// MARK: - Specification Tests: teamCount
@Test("teamCount: equals teamCanonicalIds.count")
func teamCount_equalsArrayCount() {
let division = Division(
id: "test_div",
name: "Test Division",
conference: "Test Conference",
conferenceId: "test_conf",
sport: .mlb,
teamCanonicalIds: ["team1", "team2", "team3"]
)
#expect(division.teamCount == 3)
}
@Test("teamCount: is 0 for empty array")
func teamCount_emptyArray() {
let division = Division(
id: "test_div",
name: "Test Division",
conference: "Test Conference",
conferenceId: "test_conf",
sport: .mlb,
teamCanonicalIds: []
)
#expect(division.teamCount == 0)
}
// MARK: - Invariant Tests
@Test("Invariant: teamCount == teamCanonicalIds.count")
func invariant_teamCountMatchesArray() {
let testCounts = [0, 1, 5, 10]
for count in testCounts {
let teamIds = (0..<count).map { "team_\($0)" }
let division = Division(
id: "div_\(count)",
name: "Division \(count)",
conference: "Conference",
conferenceId: "conf",
sport: .mlb,
teamCanonicalIds: teamIds
)
#expect(division.teamCount == division.teamCanonicalIds.count)
}
}
}
// MARK: - Conference Tests
@Suite("Conference")
struct ConferenceTests {
@Test("Property: divisionIds is accessible")
func property_divisionIds() {
let conference = Conference(
id: "test_conf",
name: "Test Conference",
abbreviation: "TC",
sport: .mlb,
divisionIds: ["div1", "div2", "div3"]
)
#expect(conference.divisionIds.count == 3)
#expect(conference.divisionIds.contains("div1"))
}
@Test("Property: abbreviation can be nil")
func property_abbreviationNil() {
let conference = Conference(
id: "test_conf",
name: "Test Conference",
abbreviation: nil,
sport: .nba,
divisionIds: []
)
#expect(conference.abbreviation == nil)
}
}
// MARK: - LeagueStructure Tests
@Suite("LeagueStructure")
struct LeagueStructureTests {
// MARK: - Specification Tests: divisions(for:)
@Test("divisions(for:): MLB returns 6 divisions")
func divisions_mlb() {
let divisions = LeagueStructure.divisions(for: .mlb)
#expect(divisions.count == 6)
}
@Test("divisions(for:): NBA returns 6 divisions")
func divisions_nba() {
let divisions = LeagueStructure.divisions(for: .nba)
#expect(divisions.count == 6)
}
@Test("divisions(for:): NHL returns 4 divisions")
func divisions_nhl() {
let divisions = LeagueStructure.divisions(for: .nhl)
#expect(divisions.count == 4)
}
@Test("divisions(for:): MLS returns empty array")
func divisions_mls() {
let divisions = LeagueStructure.divisions(for: .mls)
#expect(divisions.isEmpty)
}
@Test("divisions(for:): NFL returns empty array")
func divisions_nfl() {
let divisions = LeagueStructure.divisions(for: .nfl)
#expect(divisions.isEmpty)
}
// MARK: - Specification Tests: conferences(for:)
@Test("conferences(for:): MLB returns 2 conferences")
func conferences_mlb() {
let conferences = LeagueStructure.conferences(for: .mlb)
#expect(conferences.count == 2)
}
@Test("conferences(for:): NBA returns 2 conferences")
func conferences_nba() {
let conferences = LeagueStructure.conferences(for: .nba)
#expect(conferences.count == 2)
}
@Test("conferences(for:): NHL returns 2 conferences")
func conferences_nhl() {
let conferences = LeagueStructure.conferences(for: .nhl)
#expect(conferences.count == 2)
}
@Test("conferences(for:): MLS returns empty array")
func conferences_mls() {
let conferences = LeagueStructure.conferences(for: .mls)
#expect(conferences.isEmpty)
}
// MARK: - Specification Tests: division(byId:)
@Test("division(byId:): finds MLB division")
func divisionById_found() {
let division = LeagueStructure.division(byId: "mlb_al_east")
#expect(division != nil)
#expect(division?.name == "AL East")
#expect(division?.sport == .mlb)
}
@Test("division(byId:): finds NBA division")
func divisionById_nba() {
let division = LeagueStructure.division(byId: "nba_pacific")
#expect(division != nil)
#expect(division?.name == "Pacific")
#expect(division?.sport == .nba)
}
@Test("division(byId:): finds NHL division")
func divisionById_nhl() {
let division = LeagueStructure.division(byId: "nhl_metropolitan")
#expect(division != nil)
#expect(division?.name == "Metropolitan")
#expect(division?.sport == .nhl)
}
@Test("division(byId:): returns nil for unknown ID")
func divisionById_notFound() {
let division = LeagueStructure.division(byId: "unknown_division")
#expect(division == nil)
}
// MARK: - Specification Tests: conference(byId:)
@Test("conference(byId:): finds MLB conference")
func conferenceById_found() {
let conference = LeagueStructure.conference(byId: "mlb_nl")
#expect(conference != nil)
#expect(conference?.name == "National League")
#expect(conference?.abbreviation == "NL")
}
@Test("conference(byId:): finds NBA conference")
func conferenceById_nba() {
let conference = LeagueStructure.conference(byId: "nba_western")
#expect(conference != nil)
#expect(conference?.name == "Western Conference")
}
@Test("conference(byId:): returns nil for unknown ID")
func conferenceById_notFound() {
let conference = LeagueStructure.conference(byId: "unknown_conference")
#expect(conference == nil)
}
// MARK: - Specification Tests: stadiumCount(for:)
@Test("stadiumCount(for:): MLB returns 30")
func stadiumCount_mlb() {
#expect(LeagueStructure.stadiumCount(for: .mlb) == 30)
}
@Test("stadiumCount(for:): NBA returns 30")
func stadiumCount_nba() {
#expect(LeagueStructure.stadiumCount(for: .nba) == 30)
}
@Test("stadiumCount(for:): NHL returns 32")
func stadiumCount_nhl() {
#expect(LeagueStructure.stadiumCount(for: .nhl) == 32)
}
@Test("stadiumCount(for:): MLS returns 0")
func stadiumCount_mls() {
#expect(LeagueStructure.stadiumCount(for: .mls) == 0)
}
// MARK: - Invariant Tests
@Test("Invariant: MLB has 3 AL + 3 NL divisions")
func invariant_mlbDivisionStructure() {
let divisions = LeagueStructure.divisions(for: .mlb)
let alDivisions = divisions.filter { $0.conferenceId == "mlb_al" }
let nlDivisions = divisions.filter { $0.conferenceId == "mlb_nl" }
#expect(alDivisions.count == 3)
#expect(nlDivisions.count == 3)
}
@Test("Invariant: NBA has 3 Eastern + 3 Western divisions")
func invariant_nbaDivisionStructure() {
let divisions = LeagueStructure.divisions(for: .nba)
let eastern = divisions.filter { $0.conferenceId == "nba_eastern" }
let western = divisions.filter { $0.conferenceId == "nba_western" }
#expect(eastern.count == 3)
#expect(western.count == 3)
}
@Test("Invariant: NHL has 2 Eastern + 2 Western divisions")
func invariant_nhlDivisionStructure() {
let divisions = LeagueStructure.divisions(for: .nhl)
let eastern = divisions.filter { $0.conferenceId == "nhl_eastern" }
let western = divisions.filter { $0.conferenceId == "nhl_western" }
#expect(eastern.count == 2)
#expect(western.count == 2)
}
@Test("Invariant: each conference contains valid division IDs")
func invariant_conferenceContainsValidDivisions() {
for conference in LeagueStructure.conferences {
for divisionId in conference.divisionIds {
let division = LeagueStructure.division(byId: divisionId)
#expect(division != nil, "Division \(divisionId) in conference \(conference.id) should exist")
#expect(division?.conferenceId == conference.id, "Division \(divisionId) should belong to conference \(conference.id)")
}
}
}
@Test("Invariant: all divisions have unique IDs")
func invariant_uniqueDivisionIds() {
let allDivisions = LeagueStructure.mlbDivisions + LeagueStructure.nbaDivisions + LeagueStructure.nhlDivisions
let ids = allDivisions.map { $0.id }
let uniqueIds = Set(ids)
#expect(ids.count == uniqueIds.count, "Division IDs should be unique")
}
@Test("Invariant: all conferences have unique IDs")
func invariant_uniqueConferenceIds() {
let ids = LeagueStructure.conferences.map { $0.id }
let uniqueIds = Set(ids)
#expect(ids.count == uniqueIds.count, "Conference IDs should be unique")
}
}

View File

@@ -2,112 +2,181 @@
// DynamicSportTests.swift
// SportsTimeTests
//
// TDD specification tests for DynamicSport model.
//
import Testing
import Foundation
import SwiftUI
@testable import SportsTime
@Suite("DynamicSport")
struct DynamicSportTests {
@Test("DynamicSport conforms to AnySport protocol")
func dynamicSportConformsToAnySport() {
let xfl = DynamicSport(
id: "xfl",
abbreviation: "XFL",
displayName: "XFL Football",
iconName: "football.fill",
colorHex: "#E31837",
seasonStartMonth: 2,
seasonEndMonth: 5
)
// MARK: - Test Data
let sport: any AnySport = xfl
#expect(sport.sportId == "xfl")
#expect(sport.displayName == "XFL Football")
#expect(sport.iconName == "football.fill")
private func makeDynamicSport(
id: String = "xfl",
abbreviation: String = "XFL",
displayName: String = "XFL Football",
iconName: String = "football.fill",
colorHex: String = "#FF0000",
seasonStart: Int = 2,
seasonEnd: Int = 5
) -> DynamicSport {
DynamicSport(
id: id,
abbreviation: abbreviation,
displayName: displayName,
iconName: iconName,
colorHex: colorHex,
seasonStartMonth: seasonStart,
seasonEndMonth: seasonEnd
)
}
@Test("DynamicSport color parses from hex")
func dynamicSportColorParsesFromHex() {
let sport = DynamicSport(
id: "test",
private var calendar: Calendar { Calendar.current }
private func date(month: Int) -> Date {
calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
}
// MARK: - Specification Tests: AnySport Conformance
@Test("sportId: returns id")
func sportId_returnsId() {
let sport = makeDynamicSport(id: "test_sport")
#expect(sport.sportId == "test_sport")
}
@Test("displayName: returns displayName property")
func displayName_returnsProperty() {
let sport = makeDynamicSport(displayName: "Test Sport Name")
#expect(sport.displayName == "Test Sport Name")
}
@Test("iconName: returns iconName property")
func iconName_returnsProperty() {
let sport = makeDynamicSport(iconName: "custom.icon")
#expect(sport.iconName == "custom.icon")
}
@Test("seasonMonths: returns (seasonStartMonth, seasonEndMonth)")
func seasonMonths_returnsTuple() {
let sport = makeDynamicSport(seasonStart: 3, seasonEnd: 10)
let (start, end) = sport.seasonMonths
#expect(start == 3)
#expect(end == 10)
}
@Test("color: converts colorHex to Color")
func color_convertsHex() {
let sport = makeDynamicSport(colorHex: "#FF0000")
// We can't directly compare Colors, but we can verify it doesn't crash
// and returns some color value
let _ = sport.color
}
// MARK: - Specification Tests: isInSeason (via AnySport)
@Test("isInSeason: uses default AnySport implementation for normal season")
func isInSeason_normalSeason() {
// Season: February to May (normal range)
let sport = makeDynamicSport(seasonStart: 2, seasonEnd: 5)
#expect(sport.isInSeason(for: date(month: 2)) == true)
#expect(sport.isInSeason(for: date(month: 3)) == true)
#expect(sport.isInSeason(for: date(month: 5)) == true)
#expect(sport.isInSeason(for: date(month: 1)) == false)
#expect(sport.isInSeason(for: date(month: 6)) == false)
}
@Test("isInSeason: uses default AnySport implementation for wrap-around season")
func isInSeason_wrapAroundSeason() {
// Season: October to April (wraps around)
let sport = makeDynamicSport(seasonStart: 10, seasonEnd: 4)
#expect(sport.isInSeason(for: date(month: 10)) == true)
#expect(sport.isInSeason(for: date(month: 12)) == true)
#expect(sport.isInSeason(for: date(month: 1)) == true)
#expect(sport.isInSeason(for: date(month: 4)) == true)
#expect(sport.isInSeason(for: date(month: 6)) == false)
#expect(sport.isInSeason(for: date(month: 8)) == false)
}
// MARK: - Specification Tests: Identifiable Conformance
@Test("id: returns id property")
func id_returnsIdProperty() {
let sport = makeDynamicSport(id: "unique_id")
#expect(sport.id == "unique_id")
}
// MARK: - Specification Tests: Hashable Conformance
@Test("hashable: same values are equal")
func hashable_equality() {
let sport1 = makeDynamicSport(id: "test")
let sport2 = makeDynamicSport(id: "test")
#expect(sport1 == sport2)
}
@Test("hashable: different ids are not equal")
func hashable_inequality() {
let sport1 = makeDynamicSport(id: "test1")
let sport2 = makeDynamicSport(id: "test2")
#expect(sport1 != sport2)
}
// MARK: - Specification Tests: Codable Conformance
@Test("codable: encodes and decodes correctly")
func codable_roundTrip() throws {
let sport = makeDynamicSport(
id: "test_sport",
abbreviation: "TST",
displayName: "Test Sport",
iconName: "star.fill",
colorHex: "#FF0000",
seasonStartMonth: 1,
seasonEndMonth: 12
colorHex: "#00FF00",
seasonStart: 4,
seasonEnd: 9
)
// Color should be red
#expect(sport.color != Color.clear)
}
@Test("DynamicSport isInSeason works correctly")
func dynamicSportIsInSeason() {
let xfl = DynamicSport(
id: "xfl",
abbreviation: "XFL",
displayName: "XFL Football",
iconName: "football.fill",
colorHex: "#E31837",
seasonStartMonth: 2,
seasonEndMonth: 5
)
// March is in XFL season (Feb-May)
let march = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 15))!
#expect(xfl.isInSeason(for: march))
// September is not in XFL season
let september = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 15))!
#expect(!xfl.isInSeason(for: september))
}
@Test("DynamicSport is Hashable")
func dynamicSportIsHashable() {
let sport1 = DynamicSport(
id: "xfl",
abbreviation: "XFL",
displayName: "XFL Football",
iconName: "football.fill",
colorHex: "#E31837",
seasonStartMonth: 2,
seasonEndMonth: 5
)
let sport2 = DynamicSport(
id: "xfl",
abbreviation: "XFL",
displayName: "XFL Football",
iconName: "football.fill",
colorHex: "#E31837",
seasonStartMonth: 2,
seasonEndMonth: 5
)
let set: Set<DynamicSport> = [sport1, sport2]
#expect(set.count == 1)
}
@Test("DynamicSport is Codable")
func dynamicSportIsCodable() throws {
let original = DynamicSport(
id: "xfl",
abbreviation: "XFL",
displayName: "XFL Football",
iconName: "football.fill",
colorHex: "#E31837",
seasonStartMonth: 2,
seasonEndMonth: 5
)
let encoded = try JSONEncoder().encode(original)
let encoded = try JSONEncoder().encode(sport)
let decoded = try JSONDecoder().decode(DynamicSport.self, from: encoded)
#expect(decoded.id == original.id)
#expect(decoded.abbreviation == original.abbreviation)
#expect(decoded.displayName == original.displayName)
#expect(decoded.id == sport.id)
#expect(decoded.abbreviation == sport.abbreviation)
#expect(decoded.displayName == sport.displayName)
#expect(decoded.iconName == sport.iconName)
#expect(decoded.colorHex == sport.colorHex)
#expect(decoded.seasonStartMonth == sport.seasonStartMonth)
#expect(decoded.seasonEndMonth == sport.seasonEndMonth)
}
// MARK: - Invariant Tests
@Test("Invariant: sportId == id")
func invariant_sportIdEqualsId() {
let sport = makeDynamicSport(id: "any_id")
#expect(sport.sportId == sport.id)
}
@Test("Invariant: seasonMonths matches individual properties")
func invariant_seasonMonthsMatchesProperties() {
let sport = makeDynamicSport(seasonStart: 5, seasonEnd: 11)
let (start, end) = sport.seasonMonths
#expect(start == sport.seasonStartMonth)
#expect(end == sport.seasonEndMonth)
}
}

View File

@@ -0,0 +1,211 @@
//
// GameTests.swift
// SportsTimeTests
//
// TDD specification tests for Game model.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("Game")
@MainActor
struct GameTests {
// MARK: - Test Data
private func makeGame(dateTime: Date) -> Game {
Game(
id: "game1",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: "stadium1",
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
}
// MARK: - Specification Tests: gameDate
@Test("gameDate returns start of day for dateTime")
func gameDate_returnsStartOfDay() {
let calendar = Calendar.current
// Game at 7:05 PM
let dateTime = calendar.date(from: DateComponents(
year: 2026, month: 6, day: 15,
hour: 19, minute: 5, second: 0
))!
let game = makeGame(dateTime: dateTime)
let expectedStart = calendar.startOfDay(for: dateTime)
#expect(game.gameDate == expectedStart)
// Verify it's at midnight
let components = calendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
#expect(components.hour == 0)
#expect(components.minute == 0)
#expect(components.second == 0)
}
@Test("gameDate is same for games on same calendar day")
func gameDate_sameDay() {
let calendar = Calendar.current
// Morning game
let morningTime = calendar.date(from: DateComponents(
year: 2026, month: 6, day: 15,
hour: 10, minute: 0
))!
// Evening game
let eveningTime = calendar.date(from: DateComponents(
year: 2026, month: 6, day: 15,
hour: 20, minute: 0
))!
let morningGame = makeGame(dateTime: morningTime)
let eveningGame = makeGame(dateTime: eveningTime)
#expect(morningGame.gameDate == eveningGame.gameDate)
}
@Test("gameDate differs for games on different calendar days")
func gameDate_differentDays() {
let calendar = Calendar.current
let day1 = calendar.date(from: DateComponents(
year: 2026, month: 6, day: 15, hour: 19
))!
let day2 = calendar.date(from: DateComponents(
year: 2026, month: 6, day: 16, hour: 19
))!
let game1 = makeGame(dateTime: day1)
let game2 = makeGame(dateTime: day2)
#expect(game1.gameDate != game2.gameDate)
}
// MARK: - Specification Tests: startTime Alias
@Test("startTime is alias for dateTime")
func startTime_isAliasForDateTime() {
let dateTime = Date()
let game = makeGame(dateTime: dateTime)
#expect(game.startTime == game.dateTime)
}
// MARK: - Specification Tests: Equality
@Test("equality based on id only")
func equality_basedOnId() {
let dateTime = Date()
let game1 = Game(
id: "game1",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: "stadium1",
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
// Same id, different fields
let game2 = Game(
id: "game1",
homeTeamId: "different-team",
awayTeamId: "different-team2",
stadiumId: "different-stadium",
dateTime: dateTime.addingTimeInterval(3600),
sport: .nba,
season: "2027",
isPlayoff: true
)
#expect(game1 == game2, "Games with same id should be equal")
}
@Test("inequality when ids differ")
func inequality_differentIds() {
let dateTime = Date()
let game1 = Game(
id: "game1",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: "stadium1",
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
let game2 = Game(
id: "game2",
homeTeamId: "team1",
awayTeamId: "team2",
stadiumId: "stadium1",
dateTime: dateTime,
sport: .mlb,
season: "2026",
isPlayoff: false
)
#expect(game1 != game2, "Games with different ids should not be equal")
}
// MARK: - Invariant Tests
@Test("Invariant: gameDate is always at midnight")
func invariant_gameDateAtMidnight() {
let calendar = Calendar.current
// Test various times throughout the day
let times = [0, 6, 12, 18, 23].map { hour in
calendar.date(from: DateComponents(year: 2026, month: 6, day: 15, hour: hour))!
}
for time in times {
let game = makeGame(dateTime: time)
let components = calendar.dateComponents([.hour, .minute, .second], from: game.gameDate)
#expect(components.hour == 0, "gameDate hour should be 0")
#expect(components.minute == 0, "gameDate minute should be 0")
#expect(components.second == 0, "gameDate second should be 0")
}
}
@Test("Invariant: startTime equals dateTime")
func invariant_startTimeEqualsDateTime() {
for _ in 0..<10 {
let dateTime = Date().addingTimeInterval(Double.random(in: -86400...86400))
let game = makeGame(dateTime: dateTime)
#expect(game.startTime == game.dateTime)
}
}
// MARK: - Property Tests
@Test("Property: gameDate is in same calendar day as dateTime")
func property_gameDateSameCalendarDay() {
let calendar = Calendar.current
let dateTime = calendar.date(from: DateComponents(
year: 2026, month: 7, day: 4, hour: 19, minute: 5
))!
let game = makeGame(dateTime: dateTime)
let dateTimeDay = calendar.component(.day, from: dateTime)
let gameDateDay = calendar.component(.day, from: game.gameDate)
#expect(dateTimeDay == gameDateDay)
}
}

View File

@@ -1,309 +0,0 @@
//
// PollTests.swift
// SportsTimeTests
//
// Tests for TripPoll, PollVote, and PollResults domain models
//
import Testing
@testable import SportsTime
import Foundation
// MARK: - TripPoll Tests
struct TripPollTests {
// MARK: - Share Code Tests
@Test("Share code has correct length")
func shareCode_HasCorrectLength() {
let code = TripPoll.generateShareCode()
#expect(code.count == 6)
}
@Test("Share code contains only allowed characters")
func shareCode_ContainsOnlyAllowedCharacters() {
let allowedCharacters = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(allowedCharacters.contains(char), "Unexpected character: \(char)")
}
}
}
@Test("Share code excludes ambiguous characters")
func shareCode_ExcludesAmbiguousCharacters() {
let ambiguousCharacters = Set("0O1IL")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(!ambiguousCharacters.contains(char), "Found ambiguous character: \(char)")
}
}
}
@Test("Share codes are unique")
func shareCode_IsUnique() {
var codes = Set<String>()
for _ in 0..<1000 {
let code = TripPoll.generateShareCode()
codes.insert(code)
}
// With 6 chars from 32 possibilities, collisions in 1000 samples should be rare
#expect(codes.count >= 990, "Too many collisions in share code generation")
}
// MARK: - Share URL Tests
@Test("Share URL is correctly formatted")
func shareURL_IsCorrectlyFormatted() {
let poll = makeTestPoll(shareCode: "ABC123")
#expect(poll.shareURL.absoluteString == "sportstime://poll/ABC123")
}
// MARK: - Trip Hash Tests
@Test("Trip hash is deterministic")
func tripHash_IsDeterministic() {
let trip = makeTestTrip(cities: ["Chicago", "Detroit"])
let hash1 = TripPoll.computeTripHash(trip)
let hash2 = TripPoll.computeTripHash(trip)
#expect(hash1 == hash2)
}
@Test("Trip hash differs for different cities")
func tripHash_DiffersForDifferentCities() {
let trip1 = makeTestTrip(cities: ["Chicago", "Detroit"])
let trip2 = makeTestTrip(cities: ["Chicago", "Milwaukee"])
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
@Test("Trip hash differs for different dates")
func tripHash_DiffersForDifferentDates() {
let baseDate = Date()
let trip1 = makeTestTrip(cities: ["Chicago"], startDate: baseDate)
let trip2 = makeTestTrip(cities: ["Chicago"], startDate: baseDate.addingTimeInterval(86400))
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
// MARK: - Initialization Tests
@Test("Poll initializes with trip versions")
func poll_InitializesWithTripVersions() {
let trip1 = makeTestTrip(cities: ["Chicago"])
let trip2 = makeTestTrip(cities: ["Detroit"])
let poll = TripPoll(
title: "Test Poll",
ownerId: "user123",
tripSnapshots: [trip1, trip2]
)
#expect(poll.tripVersions.count == 2)
#expect(poll.tripVersions[0] == TripPoll.computeTripHash(trip1))
#expect(poll.tripVersions[1] == TripPoll.computeTripHash(trip2))
}
}
// MARK: - PollVote Tests
struct PollVoteTests {
// MARK: - Borda Count Tests
@Test("Borda count scores 3 trips correctly")
func bordaCount_Scores3TripsCorrectly() {
// Rankings: [2, 0, 1] means trip 2 is #1, trip 0 is #2, trip 1 is #3
let rankings = [2, 0, 1]
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 3)
// Trip 2 is rank 0 (first place): 3 - 0 = 3 points
// Trip 0 is rank 1 (second place): 3 - 1 = 2 points
// Trip 1 is rank 2 (third place): 3 - 2 = 1 point
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 1, "Trip 1 should have 1 point")
#expect(scores[2] == 3, "Trip 2 should have 3 points")
}
@Test("Borda count scores 2 trips correctly")
func bordaCount_Scores2TripsCorrectly() {
let rankings = [1, 0] // Trip 1 first, Trip 0 second
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 1, "Trip 0 should have 1 point")
#expect(scores[1] == 2, "Trip 1 should have 2 points")
}
@Test("Borda count handles invalid trip index")
func bordaCount_HandlesInvalidTripIndex() {
let rankings = [0, 5] // 5 is out of bounds for tripCount 2
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 0, "Trip 1 should have 0 points (never ranked)")
}
@Test("Borda count with 5 trips")
func bordaCount_With5Trips() {
// Rankings: trip indices in preference order
let rankings = [4, 2, 0, 3, 1] // Trip 4 is best, trip 1 is worst
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 5)
// Points: 5 for 1st, 4 for 2nd, 3 for 3rd, 2 for 4th, 1 for 5th
#expect(scores[0] == 3, "Trip 0 (3rd place) should have 3 points")
#expect(scores[1] == 1, "Trip 1 (5th place) should have 1 point")
#expect(scores[2] == 4, "Trip 2 (2nd place) should have 4 points")
#expect(scores[3] == 2, "Trip 3 (4th place) should have 2 points")
#expect(scores[4] == 5, "Trip 4 (1st place) should have 5 points")
}
}
// MARK: - PollResults Tests
struct PollResultsTests {
@Test("Results with no votes returns zero scores")
func results_NoVotesReturnsZeroScores() {
let poll = makeTestPoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
#expect(results.voterCount == 0)
#expect(results.maxScore == 0)
#expect(results.tripScores.count == 3)
for item in results.tripScores {
#expect(item.score == 0)
}
}
@Test("Results with single vote")
func results_SingleVote() {
let poll = makeTestPoll(tripCount: 3)
let vote = PollVote(pollId: poll.id, odg: "voter1", rankings: [2, 0, 1])
let results = PollResults(poll: poll, votes: [vote])
#expect(results.voterCount == 1)
#expect(results.maxScore == 3)
// Trip 2 should be first with 3 points
#expect(results.tripScores[0].tripIndex == 2)
#expect(results.tripScores[0].score == 3)
// Trip 0 should be second with 2 points
#expect(results.tripScores[1].tripIndex == 0)
#expect(results.tripScores[1].score == 2)
// Trip 1 should be third with 1 point
#expect(results.tripScores[2].tripIndex == 1)
#expect(results.tripScores[2].score == 1)
}
@Test("Results aggregates multiple votes")
func results_AggregatesMultipleVotes() {
let poll = makeTestPoll(tripCount: 3)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1, 2]) // Trip 0 first
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 2, 1]) // Trip 0 first
let vote3 = PollVote(pollId: poll.id, odg: "voter3", rankings: [1, 0, 2]) // Trip 1 first
let results = PollResults(poll: poll, votes: [vote1, vote2, vote3])
#expect(results.voterCount == 3)
// Trip 0: 3 + 3 + 2 = 8 points (first, first, second)
// Trip 1: 2 + 1 + 3 = 6 points (second, third, first)
// Trip 2: 1 + 2 + 1 = 4 points (third, second, third)
#expect(results.tripScores[0].tripIndex == 0, "Trip 0 should be ranked first")
#expect(results.tripScores[0].score == 8)
#expect(results.tripScores[1].tripIndex == 1, "Trip 1 should be ranked second")
#expect(results.tripScores[1].score == 6)
#expect(results.tripScores[2].tripIndex == 2, "Trip 2 should be ranked third")
#expect(results.tripScores[2].score == 4)
}
@Test("Score percentage calculation")
func results_ScorePercentageCalculation() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 1])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 2 = 4 points (max)
// Trip 1: 1 + 1 = 2 points
#expect(results.scorePercentage(for: 0) == 1.0, "Trip 0 should be 100%")
#expect(results.scorePercentage(for: 1) == 0.5, "Trip 1 should be 50%")
}
@Test("Score percentage returns zero when no votes")
func results_ScorePercentageReturnsZeroWhenNoVotes() {
let poll = makeTestPoll(tripCount: 2)
let results = PollResults(poll: poll, votes: [])
#expect(results.scorePercentage(for: 0) == 0)
#expect(results.scorePercentage(for: 1) == 0)
}
@Test("Results handles tie correctly")
func results_HandlesTieCorrectly() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [1, 0])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 1 = 3 points
// Trip 1: 1 + 2 = 3 points
// Both tied at 3
#expect(results.tripScores[0].score == 3)
#expect(results.tripScores[1].score == 3)
#expect(results.maxScore == 3)
}
}
// MARK: - Test Helpers
private func makeTestTrip(
cities: [String],
startDate: Date = Date(),
games: [String] = []
) -> Trip {
let stops = cities.enumerated().map { index, city in
TripStop(
stopNumber: index + 1,
city: city,
state: "XX",
arrivalDate: startDate.addingTimeInterval(Double(index) * 86400),
departureDate: startDate.addingTimeInterval(Double(index + 1) * 86400),
games: games
)
}
return Trip(
name: "Test Trip",
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: startDate.addingTimeInterval(86400 * Double(cities.count))
),
stops: stops
)
}
private func makeTestPoll(tripCount: Int = 3, shareCode: String? = nil) -> TripPoll {
let trips = (0..<tripCount).map { index in
makeTestTrip(cities: ["City\(index)"])
}
return TripPoll(
title: "Test Poll",
ownerId: "testOwner",
shareCode: shareCode ?? TripPoll.generateShareCode(),
tripSnapshots: trips
)
}

View File

@@ -0,0 +1,572 @@
//
// ProgressTests.swift
// SportsTimeTests
//
// TDD specification tests for Progress models (LeagueProgress, DivisionProgress, etc.).
//
import Testing
import Foundation
@testable import SportsTime
@Suite("LeagueProgress")
struct LeagueProgressTests {
// MARK: - Test Data
private func makeLeagueProgress(visited: Int, total: Int) -> LeagueProgress {
LeagueProgress(
sport: .mlb,
totalStadiums: total,
visitedStadiums: visited,
stadiumsVisited: [],
stadiumsRemaining: []
)
}
// MARK: - Specification Tests: completionPercentage
@Test("completionPercentage: 50% when half visited")
func completionPercentage_half() {
let progress = makeLeagueProgress(visited: 15, total: 30)
#expect(progress.completionPercentage == 50.0)
}
@Test("completionPercentage: 100% when all visited")
func completionPercentage_complete() {
let progress = makeLeagueProgress(visited: 30, total: 30)
#expect(progress.completionPercentage == 100.0)
}
@Test("completionPercentage: 0% when none visited")
func completionPercentage_zero() {
let progress = makeLeagueProgress(visited: 0, total: 30)
#expect(progress.completionPercentage == 0.0)
}
@Test("completionPercentage: 0 when total is 0")
func completionPercentage_totalZero() {
let progress = makeLeagueProgress(visited: 0, total: 0)
#expect(progress.completionPercentage == 0)
}
// MARK: - Specification Tests: progressFraction
@Test("progressFraction: 0.5 when half visited")
func progressFraction_half() {
let progress = makeLeagueProgress(visited: 15, total: 30)
#expect(progress.progressFraction == 0.5)
}
@Test("progressFraction: 1.0 when complete")
func progressFraction_complete() {
let progress = makeLeagueProgress(visited: 30, total: 30)
#expect(progress.progressFraction == 1.0)
}
@Test("progressFraction: 0 when total is 0")
func progressFraction_totalZero() {
let progress = makeLeagueProgress(visited: 5, total: 0)
#expect(progress.progressFraction == 0)
}
// MARK: - Specification Tests: isComplete
@Test("isComplete: true when visited >= total and total > 0")
func isComplete_true() {
let progress = makeLeagueProgress(visited: 30, total: 30)
#expect(progress.isComplete == true)
}
@Test("isComplete: false when visited < total")
func isComplete_false() {
let progress = makeLeagueProgress(visited: 29, total: 30)
#expect(progress.isComplete == false)
}
@Test("isComplete: false when total is 0")
func isComplete_totalZero() {
let progress = makeLeagueProgress(visited: 0, total: 0)
#expect(progress.isComplete == false)
}
// MARK: - Specification Tests: progressDescription
@Test("progressDescription: formats as visited/total")
func progressDescription_format() {
let progress = makeLeagueProgress(visited: 15, total: 30)
#expect(progress.progressDescription == "15/30")
}
// MARK: - Invariant Tests
@Test("Invariant: completionPercentage in range [0, 100]")
func invariant_percentageRange() {
let testCases = [
(visited: 0, total: 30),
(visited: 15, total: 30),
(visited: 30, total: 30),
(visited: 0, total: 0),
]
for (visited, total) in testCases {
let progress = makeLeagueProgress(visited: visited, total: total)
#expect(progress.completionPercentage >= 0)
#expect(progress.completionPercentage <= 100)
}
}
@Test("Invariant: progressFraction in range [0, 1]")
func invariant_fractionRange() {
let testCases = [
(visited: 0, total: 30),
(visited: 15, total: 30),
(visited: 30, total: 30),
(visited: 0, total: 0),
]
for (visited, total) in testCases {
let progress = makeLeagueProgress(visited: visited, total: total)
#expect(progress.progressFraction >= 0)
#expect(progress.progressFraction <= 1)
}
}
@Test("Invariant: isComplete requires total > 0")
func invariant_isCompleteRequiresTotal() {
// Can't be complete with 0 total
let progress = makeLeagueProgress(visited: 0, total: 0)
#expect(progress.isComplete == false)
}
}
// MARK: - DivisionProgress Tests
@Suite("DivisionProgress")
struct DivisionProgressTests {
private func makeDivisionProgress(visited: Int, total: Int) -> DivisionProgress {
let division = Division(
id: "test_div",
name: "Test Division",
conference: "Test Conference",
conferenceId: "test_conf",
sport: .mlb,
teamCanonicalIds: []
)
return DivisionProgress(
division: division,
totalStadiums: total,
visitedStadiums: visited,
stadiumsVisited: [],
stadiumsRemaining: []
)
}
@Test("completionPercentage: calculates correctly")
func completionPercentage() {
let progress = makeDivisionProgress(visited: 3, total: 5)
#expect(progress.completionPercentage == 60.0)
}
@Test("progressFraction: calculates correctly")
func progressFraction() {
let progress = makeDivisionProgress(visited: 3, total: 5)
#expect(progress.progressFraction == 0.6)
}
@Test("isComplete: true when all visited")
func isComplete() {
let complete = makeDivisionProgress(visited: 5, total: 5)
let incomplete = makeDivisionProgress(visited: 4, total: 5)
#expect(complete.isComplete == true)
#expect(incomplete.isComplete == false)
}
}
// MARK: - ConferenceProgress Tests
@Suite("ConferenceProgress")
struct ConferenceProgressTests {
private func makeConferenceProgress(visited: Int, total: Int) -> ConferenceProgress {
let conference = Conference(
id: "test_conf",
name: "Test Conference",
abbreviation: "TC",
sport: .mlb,
divisionIds: []
)
return ConferenceProgress(
conference: conference,
totalStadiums: total,
visitedStadiums: visited,
divisionProgress: []
)
}
@Test("completionPercentage: calculates correctly")
func completionPercentage() {
let progress = makeConferenceProgress(visited: 10, total: 15)
#expect(abs(progress.completionPercentage - 66.666) < 0.01)
}
@Test("isComplete: true when all visited")
func isComplete() {
let complete = makeConferenceProgress(visited: 15, total: 15)
let incomplete = makeConferenceProgress(visited: 14, total: 15)
#expect(complete.isComplete == true)
#expect(incomplete.isComplete == false)
}
}
// MARK: - OverallProgress Tests
@Suite("OverallProgress")
struct OverallProgressTests {
@Test("overallPercentage: calculates correctly")
func overallPercentage() {
let progress = OverallProgress(
leagueProgress: [],
totalVisits: 50,
uniqueStadiumsVisited: 46,
totalStadiumsAcrossLeagues: 92,
achievementsEarned: 10,
totalAchievements: 50
)
#expect(progress.overallPercentage == 50.0)
}
@Test("overallPercentage: 0 when no stadiums")
func overallPercentage_zero() {
let progress = OverallProgress(
leagueProgress: [],
totalVisits: 0,
uniqueStadiumsVisited: 0,
totalStadiumsAcrossLeagues: 0,
achievementsEarned: 0,
totalAchievements: 0
)
#expect(progress.overallPercentage == 0)
}
@Test("progress(for:): finds league progress")
func progressForSport() {
let mlbProgress = LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 15,
stadiumsVisited: [],
stadiumsRemaining: []
)
let overall = OverallProgress(
leagueProgress: [mlbProgress],
totalVisits: 15,
uniqueStadiumsVisited: 15,
totalStadiumsAcrossLeagues: 92,
achievementsEarned: 5,
totalAchievements: 50
)
let found = overall.progress(for: .mlb)
#expect(found != nil)
#expect(found?.visitedStadiums == 15)
}
@Test("progress(for:): returns nil for missing sport")
func progressForSport_notFound() {
let overall = OverallProgress(
leagueProgress: [],
totalVisits: 0,
uniqueStadiumsVisited: 0,
totalStadiumsAcrossLeagues: 92,
achievementsEarned: 0,
totalAchievements: 50
)
#expect(overall.progress(for: .mlb) == nil)
}
}
// MARK: - StadiumVisitStatus Tests
@Suite("StadiumVisitStatus")
struct StadiumVisitStatusTests {
private func makeVisitSummary(date: Date) -> VisitSummary {
VisitSummary(
id: UUID(),
stadium: Stadium(
id: "stadium1",
name: "Test Stadium",
city: "Test City",
state: "TS",
latitude: 40.0,
longitude: -74.0,
capacity: 40000,
sport: .mlb
),
visitDate: date,
visitType: .game,
sport: .mlb,
homeTeamName: "Home Team",
awayTeamName: "Away Team",
score: "5-3",
photoCount: 0,
notes: nil
)
}
// MARK: - Specification Tests: isVisited
@Test("isVisited: true for visited status")
func isVisited_true() {
let visit = makeVisitSummary(date: Date())
let status = StadiumVisitStatus.visited(visits: [visit])
#expect(status.isVisited == true)
}
@Test("isVisited: false for notVisited status")
func isVisited_false() {
let status = StadiumVisitStatus.notVisited
#expect(status.isVisited == false)
}
// MARK: - Specification Tests: visitCount
@Test("visitCount: returns count of visits")
func visitCount_multiple() {
let visits = [
makeVisitSummary(date: Date()),
makeVisitSummary(date: Date()),
makeVisitSummary(date: Date()),
]
let status = StadiumVisitStatus.visited(visits: visits)
#expect(status.visitCount == 3)
}
@Test("visitCount: 0 for notVisited")
func visitCount_notVisited() {
let status = StadiumVisitStatus.notVisited
#expect(status.visitCount == 0)
}
// MARK: - Specification Tests: latestVisit
@Test("latestVisit: returns visit with max date")
func latestVisit_maxDate() {
let calendar = Calendar.current
let date1 = calendar.date(from: DateComponents(year: 2025, month: 1, day: 1))!
let date2 = calendar.date(from: DateComponents(year: 2025, month: 6, day: 15))!
let date3 = calendar.date(from: DateComponents(year: 2025, month: 3, day: 10))!
let visits = [
makeVisitSummary(date: date1),
makeVisitSummary(date: date2),
makeVisitSummary(date: date3),
]
let status = StadiumVisitStatus.visited(visits: visits)
#expect(status.latestVisit?.visitDate == date2)
}
@Test("latestVisit: nil for notVisited")
func latestVisit_notVisited() {
let status = StadiumVisitStatus.notVisited
#expect(status.latestVisit == nil)
}
// MARK: - Specification Tests: firstVisit
@Test("firstVisit: returns visit with min date")
func firstVisit_minDate() {
let calendar = Calendar.current
let date1 = calendar.date(from: DateComponents(year: 2025, month: 1, day: 1))!
let date2 = calendar.date(from: DateComponents(year: 2025, month: 6, day: 15))!
let date3 = calendar.date(from: DateComponents(year: 2025, month: 3, day: 10))!
let visits = [
makeVisitSummary(date: date1),
makeVisitSummary(date: date2),
makeVisitSummary(date: date3),
]
let status = StadiumVisitStatus.visited(visits: visits)
#expect(status.firstVisit?.visitDate == date1)
}
@Test("firstVisit: nil for notVisited")
func firstVisit_notVisited() {
let status = StadiumVisitStatus.notVisited
#expect(status.firstVisit == nil)
}
// MARK: - Invariant Tests
@Test("Invariant: visitCount == 0 for notVisited")
func invariant_visitCountZeroForNotVisited() {
let status = StadiumVisitStatus.notVisited
#expect(status.visitCount == 0)
}
@Test("Invariant: latestVisit and firstVisit are nil for notVisited")
func invariant_visitsNilForNotVisited() {
let status = StadiumVisitStatus.notVisited
#expect(status.latestVisit == nil)
#expect(status.firstVisit == nil)
}
}
// MARK: - VisitSummary Tests
@Suite("VisitSummary")
struct VisitSummaryTests {
private func makeVisitSummary(
homeTeam: String? = "Home",
awayTeam: String? = "Away"
) -> VisitSummary {
VisitSummary(
id: UUID(),
stadium: Stadium(
id: "stadium1",
name: "Test Stadium",
city: "Test City",
state: "TS",
latitude: 40.0,
longitude: -74.0,
capacity: 40000,
sport: .mlb
),
visitDate: Date(),
visitType: .game,
sport: .mlb,
homeTeamName: homeTeam,
awayTeamName: awayTeam,
score: "5-3",
photoCount: 0,
notes: nil
)
}
@Test("matchup: returns 'away @ home' format")
func matchup_format() {
let summary = makeVisitSummary(homeTeam: "Red Sox", awayTeam: "Yankees")
#expect(summary.matchup == "Yankees @ Red Sox")
}
@Test("matchup: nil when home team is nil")
func matchup_nilHome() {
let summary = makeVisitSummary(homeTeam: nil, awayTeam: "Yankees")
#expect(summary.matchup == nil)
}
@Test("matchup: nil when away team is nil")
func matchup_nilAway() {
let summary = makeVisitSummary(homeTeam: "Red Sox", awayTeam: nil)
#expect(summary.matchup == nil)
}
}
// MARK: - ProgressCardData Tests
@Suite("ProgressCardData")
struct ProgressCardDataTests {
@Test("title: includes sport display name")
func title_includesSport() {
let progress = LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 15,
stadiumsVisited: [],
stadiumsRemaining: []
)
let cardData = ProgressCardData(
sport: .mlb,
progress: progress,
username: "TestUser",
includeMap: true,
showDetailedStats: true
)
#expect(cardData.title == "Major League Baseball Stadium Quest")
}
@Test("subtitle: shows visited of total")
func subtitle_format() {
let progress = LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 15,
stadiumsVisited: [],
stadiumsRemaining: []
)
let cardData = ProgressCardData(
sport: .mlb,
progress: progress,
username: nil,
includeMap: false,
showDetailedStats: false
)
#expect(cardData.subtitle == "15 of 30 Stadiums")
}
@Test("percentageText: formats without decimals")
func percentageText_format() {
let progress = LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 15,
stadiumsVisited: [],
stadiumsRemaining: []
)
let cardData = ProgressCardData(
sport: .mlb,
progress: progress,
username: nil,
includeMap: false,
showDetailedStats: false
)
#expect(cardData.percentageText == "50%")
}
}

View File

@@ -0,0 +1,164 @@
//
// RegionTests.swift
// SportsTimeTests
//
// TDD specification tests for Region enum.
//
import Testing
@testable import SportsTime
@Suite("Region")
struct RegionTests {
// MARK: - Specification Tests: classify(longitude:)
@Test("classify: longitude > -85 returns .east")
func classify_east() {
// NYC: -73.9855
#expect(Region.classify(longitude: -73.9855) == .east)
// Boston: -71.0972
#expect(Region.classify(longitude: -71.0972) == .east)
// Miami: -80.1918
#expect(Region.classify(longitude: -80.1918) == .east)
// Toronto: -79.3832
#expect(Region.classify(longitude: -79.3832) == .east)
// Atlanta: -84.3880
#expect(Region.classify(longitude: -84.3880) == .east)
// Just above boundary
#expect(Region.classify(longitude: -84.9999) == .east)
}
@Test("classify: longitude in -110...-85 returns .central")
func classify_central() {
// Chicago: -87.6233
#expect(Region.classify(longitude: -87.6233) == .central)
// Houston: -95.3698
#expect(Region.classify(longitude: -95.3698) == .central)
// Dallas: -96.7970
#expect(Region.classify(longitude: -96.7970) == .central)
// Minneapolis: -93.2650
#expect(Region.classify(longitude: -93.2650) == .central)
// Denver: -104.9903
#expect(Region.classify(longitude: -104.9903) == .central)
// Boundary: exactly -85
#expect(Region.classify(longitude: -85.0) == .central)
// Boundary: exactly -110
#expect(Region.classify(longitude: -110.0) == .central)
}
@Test("classify: longitude < -110 returns .west")
func classify_west() {
// Los Angeles: -118.2673
#expect(Region.classify(longitude: -118.2673) == .west)
// San Francisco: -122.4194
#expect(Region.classify(longitude: -122.4194) == .west)
// Seattle: -122.3321
#expect(Region.classify(longitude: -122.3321) == .west)
// Phoenix: -112.0740
#expect(Region.classify(longitude: -112.0740) == .west)
// Las Vegas: -115.1398
#expect(Region.classify(longitude: -115.1398) == .west)
// Just below boundary
#expect(Region.classify(longitude: -110.0001) == .west)
}
// MARK: - Specification Tests: Boundary Values
@Test("classify boundary: -85 is central, -84.9999 is east")
func classify_eastCentralBoundary() {
#expect(Region.classify(longitude: -85.0) == .central)
#expect(Region.classify(longitude: -84.9999) == .east)
#expect(Region.classify(longitude: -85.0001) == .central)
}
@Test("classify boundary: -110 is central, -110.0001 is west")
func classify_centralWestBoundary() {
#expect(Region.classify(longitude: -110.0) == .central)
#expect(Region.classify(longitude: -109.9999) == .central)
#expect(Region.classify(longitude: -110.0001) == .west)
}
// MARK: - Specification Tests: Edge Cases
@Test("classify: extreme east longitude (positive) returns .east")
func classify_extremeEast() {
#expect(Region.classify(longitude: 0.0) == .east)
#expect(Region.classify(longitude: 50.0) == .east)
}
@Test("classify: extreme west longitude returns .west")
func classify_extremeWest() {
#expect(Region.classify(longitude: -180.0) == .west)
#expect(Region.classify(longitude: -150.0) == .west)
}
// MARK: - Invariant Tests
@Test("Invariant: classify never returns .crossCountry")
func invariant_classifyNeverReturnsCrossCountry() {
let testLongitudes: [Double] = [-180, -150, -120, -110.0001, -110, -100, -85.0001, -85, -70, 0, 50]
for lon in testLongitudes {
let region = Region.classify(longitude: lon)
#expect(region != .crossCountry, "classify should never return crossCountry for longitude \(lon)")
}
}
@Test("Invariant: every longitude maps to exactly one region")
func invariant_everyLongitudeMapsToOneRegion() {
// Test a range of longitudes
for lon in stride(from: -180.0, through: 50.0, by: 10.0) {
let region = Region.classify(longitude: lon)
#expect([Region.east, .central, .west].contains(region), "Longitude \(lon) should map to east, central, or west")
}
}
// MARK: - Property Tests
@Test("Property: id equals rawValue")
func property_idEqualsRawValue() {
for region in Region.allCases {
#expect(region.id == region.rawValue)
}
}
@Test("Property: displayName equals rawValue")
func property_displayNameEqualsRawValue() {
for region in Region.allCases {
#expect(region.displayName == region.rawValue)
}
}
@Test("Property: all regions have iconName")
func property_allRegionsHaveIconName() {
for region in Region.allCases {
#expect(!region.iconName.isEmpty)
}
}
// MARK: - Specification Tests: Display Properties
@Test("shortName values are correct")
func shortName_values() {
#expect(Region.east.shortName == "East")
#expect(Region.central.shortName == "Central")
#expect(Region.west.shortName == "West")
#expect(Region.crossCountry.shortName == "Coast to Coast")
}
}

View File

@@ -2,52 +2,202 @@
// SportTests.swift
// SportsTimeTests
//
// TDD specification tests for Sport enum.
//
import Foundation
import Testing
import Foundation
@testable import SportsTime
@Suite("Sport AnySport Conformance")
struct SportAnySportTests {
@Suite("Sport")
struct SportTests {
@Test("Sport conforms to AnySport protocol")
func sportConformsToAnySport() {
let sport: any AnySport = Sport.mlb
#expect(sport.sportId == "MLB")
#expect(sport.displayName == "Major League Baseball")
#expect(sport.iconName == "baseball.fill")
// MARK: - Specification Tests: Season Months
@Test("MLB season: March (3) to October (10)")
func mlb_seasonMonths() {
let (start, end) = Sport.mlb.seasonMonths
#expect(start == 3)
#expect(end == 10)
}
@Test("Sport.id equals Sport.sportId")
func sportIdEqualsSportId() {
for sport in Sport.allCases {
#expect(sport.id == sport.sportId)
@Test("NBA season: October (10) to June (6) - wraps around year")
func nba_seasonMonths() {
let (start, end) = Sport.nba.seasonMonths
#expect(start == 10)
#expect(end == 6)
}
@Test("NHL season: October (10) to June (6) - wraps around year")
func nhl_seasonMonths() {
let (start, end) = Sport.nhl.seasonMonths
#expect(start == 10)
#expect(end == 6)
}
@Test("NFL season: September (9) to February (2) - wraps around year")
func nfl_seasonMonths() {
let (start, end) = Sport.nfl.seasonMonths
#expect(start == 9)
#expect(end == 2)
}
@Test("MLS season: February (2) to December (12)")
func mls_seasonMonths() {
let (start, end) = Sport.mls.seasonMonths
#expect(start == 2)
#expect(end == 12)
}
@Test("WNBA season: May (5) to October (10)")
func wnba_seasonMonths() {
let (start, end) = Sport.wnba.seasonMonths
#expect(start == 5)
#expect(end == 10)
}
@Test("NWSL season: March (3) to November (11)")
func nwsl_seasonMonths() {
let (start, end) = Sport.nwsl.seasonMonths
#expect(start == 3)
#expect(end == 11)
}
// MARK: - Specification Tests: isInSeason (Normal Range)
@Test("MLB: isInSeason returns true for months 3-10")
func mlb_isInSeason_normalRange() {
let calendar = Calendar.current
// In season: March through October
for month in 3...10 {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(Sport.mlb.isInSeason(for: date), "MLB should be in season in month \(month)")
}
// Out of season: January, February, November, December
for month in [1, 2, 11, 12] {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(!Sport.mlb.isInSeason(for: date), "MLB should NOT be in season in month \(month)")
}
}
@Test("Sport isInSeason works correctly")
func sportIsInSeason() {
let mlb = Sport.mlb
// MARK: - Specification Tests: isInSeason (Wrap-Around)
// April is in MLB season (March-October)
let april = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 15))!
#expect(mlb.isInSeason(for: april))
@Test("NBA: isInSeason returns true for months 10-12 and 1-6 (wrap-around)")
func nba_isInSeason_wrapAround() {
let calendar = Calendar.current
// January is not in MLB season
let january = Calendar.current.date(from: DateComponents(year: 2026, month: 1, day: 15))!
#expect(!mlb.isInSeason(for: january))
// In season: October through June (wraps)
let inSeasonMonths = [10, 11, 12, 1, 2, 3, 4, 5, 6]
for month in inSeasonMonths {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(Sport.nba.isInSeason(for: date), "NBA should be in season in month \(month)")
}
// Out of season: July, August, September
for month in [7, 8, 9] {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(!Sport.nba.isInSeason(for: date), "NBA should NOT be in season in month \(month)")
}
}
@Test("Sport with wrap-around season works correctly")
func sportWrapAroundSeason() {
let nba = Sport.nba
@Test("NFL: isInSeason returns true for months 9-12 and 1-2 (wrap-around)")
func nfl_isInSeason_wrapAround() {
let calendar = Calendar.current
// December is in NBA season (October-June wraps)
let december = Calendar.current.date(from: DateComponents(year: 2026, month: 12, day: 15))!
#expect(nba.isInSeason(for: december))
// In season: September through February (wraps)
let inSeasonMonths = [9, 10, 11, 12, 1, 2]
for month in inSeasonMonths {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(Sport.nfl.isInSeason(for: date), "NFL should be in season in month \(month)")
}
// July is not in NBA season
let july = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 15))!
#expect(!nba.isInSeason(for: july))
// Out of season: March through August
for month in 3...8 {
let date = calendar.date(from: DateComponents(year: 2026, month: month, day: 15))!
#expect(!Sport.nfl.isInSeason(for: date), "NFL should NOT be in season in month \(month)")
}
}
// MARK: - Specification Tests: Boundary Values
@Test("isInSeason boundary: first and last day of season month")
func isInSeason_boundaryDays() {
let calendar = Calendar.current
// MLB: First day of March (in season)
let marchFirst = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1))!
#expect(Sport.mlb.isInSeason(for: marchFirst))
// MLB: Last day of October (in season)
let octLast = calendar.date(from: DateComponents(year: 2026, month: 10, day: 31))!
#expect(Sport.mlb.isInSeason(for: octLast))
// MLB: First day of November (out of season)
let novFirst = calendar.date(from: DateComponents(year: 2026, month: 11, day: 1))!
#expect(!Sport.mlb.isInSeason(for: novFirst))
// MLB: Last day of February (out of season)
let febLast = calendar.date(from: DateComponents(year: 2026, month: 2, day: 28))!
#expect(!Sport.mlb.isInSeason(for: febLast))
}
// MARK: - Specification Tests: Supported Sports
@Test("supported returns all 7 sports")
func supported_returnsAllSports() {
let supported = Sport.supported
#expect(supported.count == 7)
#expect(supported.contains(.mlb))
#expect(supported.contains(.nba))
#expect(supported.contains(.nhl))
#expect(supported.contains(.nfl))
#expect(supported.contains(.mls))
#expect(supported.contains(.wnba))
#expect(supported.contains(.nwsl))
}
@Test("CaseIterable matches supported")
func caseIterable_matchesSupported() {
let allCases = Set(Sport.allCases)
let supported = Set(Sport.supported)
#expect(allCases == supported)
}
// MARK: - Invariant Tests
@Test("Invariant: seasonMonths values are always 1-12")
func invariant_seasonMonthsValidRange() {
for sport in Sport.allCases {
let (start, end) = sport.seasonMonths
#expect(start >= 1 && start <= 12, "\(sport) start month must be 1-12")
#expect(end >= 1 && end <= 12, "\(sport) end month must be 1-12")
}
}
@Test("Invariant: each sport has unique displayName")
func invariant_uniqueDisplayNames() {
var displayNames: Set<String> = []
for sport in Sport.allCases {
#expect(!displayNames.contains(sport.displayName), "Duplicate displayName: \(sport.displayName)")
displayNames.insert(sport.displayName)
}
}
// MARK: - Property Tests
@Test("Property: id equals rawValue")
func property_idEqualsRawValue() {
for sport in Sport.allCases {
#expect(sport.id == sport.rawValue)
}
}
@Test("Property: sportId equals rawValue (AnySport conformance)")
func property_sportIdEqualsRawValue() {
for sport in Sport.allCases {
#expect(sport.sportId == sport.rawValue)
}
}
}

View File

@@ -0,0 +1,232 @@
//
// StadiumTests.swift
// SportsTimeTests
//
// TDD specification tests for Stadium model.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("Stadium")
struct StadiumTests {
// MARK: - Test Data
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673)
private func makeStadium(
id: String = "stadium1",
latitude: Double,
longitude: Double
) -> Stadium {
Stadium(
id: id,
name: "Test Stadium",
city: "Test City",
state: "XX",
latitude: latitude,
longitude: longitude,
capacity: 40000,
sport: .mlb
)
}
// MARK: - Specification Tests: region
@Test("region: NYC longitude returns .east")
func region_nycEast() {
let stadium = makeStadium(latitude: nycCoord.latitude, longitude: nycCoord.longitude)
#expect(stadium.region == .east)
}
@Test("region: Boston longitude returns .east")
func region_bostonEast() {
let stadium = makeStadium(latitude: bostonCoord.latitude, longitude: bostonCoord.longitude)
#expect(stadium.region == .east)
}
@Test("region: Chicago longitude returns .central")
func region_chicagoCentral() {
let stadium = makeStadium(latitude: chicagoCoord.latitude, longitude: chicagoCoord.longitude)
#expect(stadium.region == .central)
}
@Test("region: LA longitude returns .west")
func region_laWest() {
let stadium = makeStadium(latitude: laCoord.latitude, longitude: laCoord.longitude)
#expect(stadium.region == .west)
}
// MARK: - Specification Tests: coordinate
@Test("coordinate returns CLLocationCoordinate2D from lat/long")
func coordinate_returnsCorrectValue() {
let stadium = makeStadium(latitude: 40.7580, longitude: -73.9855)
#expect(stadium.coordinate.latitude == 40.7580)
#expect(stadium.coordinate.longitude == -73.9855)
}
@Test("location returns CLLocation from lat/long")
func location_returnsCorrectValue() {
let stadium = makeStadium(latitude: 40.7580, longitude: -73.9855)
#expect(stadium.location.coordinate.latitude == 40.7580)
#expect(stadium.location.coordinate.longitude == -73.9855)
}
// MARK: - Specification Tests: distance
@Test("distance between NYC and Boston stadiums")
func distance_nycToBoston() {
let nycStadium = makeStadium(id: "nyc", latitude: nycCoord.latitude, longitude: nycCoord.longitude)
let bostonStadium = makeStadium(id: "boston", latitude: bostonCoord.latitude, longitude: bostonCoord.longitude)
let distance = nycStadium.distance(to: bostonStadium)
// NYC to Boston is approximately 298-300 km / ~186 miles with these coordinates
#expect(distance > 295_000, "NYC to Boston should be > 295km")
#expect(distance < 310_000, "NYC to Boston should be < 310km")
}
@Test("distance to self is zero")
func distance_toSelf() {
let stadium = makeStadium(latitude: nycCoord.latitude, longitude: nycCoord.longitude)
let distance = stadium.distance(to: stadium)
#expect(distance == 0)
}
@Test("distance from coordinate")
func distance_fromCoordinate() {
let stadium = makeStadium(latitude: nycCoord.latitude, longitude: nycCoord.longitude)
let distance = stadium.distance(from: bostonCoord)
// Same as NYC to Boston (~298-300 km depending on exact coordinates)
#expect(distance > 295_000)
#expect(distance < 310_000)
}
// MARK: - Specification Tests: Equality
@Test("equality based on id only")
func equality_basedOnId() {
let stadium1 = Stadium(
id: "stadium1",
name: "Stadium A",
city: "City A",
state: "AA",
latitude: 40.0,
longitude: -70.0,
capacity: 40000,
sport: .mlb
)
let stadium2 = Stadium(
id: "stadium1",
name: "Different Name",
city: "Different City",
state: "BB",
latitude: 30.0,
longitude: -80.0,
capacity: 50000,
sport: .nba
)
#expect(stadium1 == stadium2, "Stadiums with same id should be equal")
}
@Test("inequality when ids differ")
func inequality_differentIds() {
let stadium1 = makeStadium(id: "stadium1", latitude: 40.0, longitude: -70.0)
let stadium2 = makeStadium(id: "stadium2", latitude: 40.0, longitude: -70.0)
#expect(stadium1 != stadium2, "Stadiums with different ids should not be equal")
}
// MARK: - Invariant Tests
@Test("Invariant: region is consistent with Region.classify")
func invariant_regionConsistentWithClassify() {
let testCases: [(lat: Double, lon: Double)] = [
(40.7580, -73.9855), // NYC
(41.8827, -87.6233), // Chicago
(34.0430, -118.2673), // LA
(42.3467, -71.0972), // Boston
(33.4484, -112.0740), // Phoenix
]
for (lat, lon) in testCases {
let stadium = makeStadium(latitude: lat, longitude: lon)
let expected = Region.classify(longitude: lon)
#expect(stadium.region == expected, "Stadium at \(lon) should be in \(expected)")
}
}
@Test("Invariant: location and coordinate match lat/long")
func invariant_locationAndCoordinateMatch() {
let stadium = makeStadium(latitude: 40.7580, longitude: -73.9855)
#expect(stadium.location.coordinate.latitude == stadium.latitude)
#expect(stadium.location.coordinate.longitude == stadium.longitude)
#expect(stadium.coordinate.latitude == stadium.latitude)
#expect(stadium.coordinate.longitude == stadium.longitude)
}
// MARK: - Property Tests
@Test("Property: fullAddress combines city and state")
func property_fullAddress() {
let stadium = Stadium(
id: "test",
name: "Test Stadium",
city: "New York",
state: "NY",
latitude: 40.0,
longitude: -74.0,
capacity: 40000,
sport: .mlb
)
#expect(stadium.fullAddress == "New York, NY")
}
@Test("Property: timeZone returns nil when identifier is nil")
func property_timeZoneNil() {
let stadium = Stadium(
id: "test",
name: "Test Stadium",
city: "Test",
state: "XX",
latitude: 40.0,
longitude: -74.0,
capacity: 40000,
sport: .mlb,
timeZoneIdentifier: nil
)
#expect(stadium.timeZone == nil)
}
@Test("Property: timeZone returns TimeZone when identifier is valid")
func property_timeZoneValid() {
let stadium = Stadium(
id: "test",
name: "Test Stadium",
city: "Test",
state: "XX",
latitude: 40.0,
longitude: -74.0,
capacity: 40000,
sport: .mlb,
timeZoneIdentifier: "America/New_York"
)
#expect(stadium.timeZone?.identifier == "America/New_York")
}
}

View File

@@ -0,0 +1,202 @@
//
// TeamTests.swift
// SportsTimeTests
//
// TDD specification tests for Team model.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("Team")
@MainActor
struct TeamTests {
// MARK: - Specification Tests: fullName
@Test("fullName: returns 'city name' when city is non-empty")
func fullName_withCity() {
let team = Team(
id: "team1",
name: "Red Sox",
abbreviation: "BOS",
sport: .mlb,
city: "Boston",
stadiumId: "stadium1"
)
#expect(team.fullName == "Boston Red Sox")
}
@Test("fullName: returns just name when city is empty")
func fullName_emptyCity() {
let team = Team(
id: "team1",
name: "Guardians",
abbreviation: "CLE",
sport: .mlb,
city: "",
stadiumId: "stadium1"
)
#expect(team.fullName == "Guardians")
}
@Test("fullName: handles city with spaces")
func fullName_cityWithSpaces() {
let team = Team(
id: "team1",
name: "Lakers",
abbreviation: "LAL",
sport: .nba,
city: "Los Angeles",
stadiumId: "stadium1"
)
#expect(team.fullName == "Los Angeles Lakers")
}
@Test("fullName: handles team name with spaces")
func fullName_teamNameWithSpaces() {
let team = Team(
id: "team1",
name: "Red Sox",
abbreviation: "BOS",
sport: .mlb,
city: "Boston",
stadiumId: "stadium1"
)
#expect(team.fullName == "Boston Red Sox")
}
// MARK: - Specification Tests: Equality
@Test("equality based on id only")
func equality_basedOnId() {
let team1 = Team(
id: "team1",
name: "Name A",
abbreviation: "AAA",
sport: .mlb,
city: "City A",
stadiumId: "stadium1"
)
let team2 = Team(
id: "team1",
name: "Different Name",
abbreviation: "BBB",
sport: .nba,
city: "Different City",
stadiumId: "different-stadium"
)
#expect(team1 == team2, "Teams with same id should be equal")
}
@Test("inequality when ids differ")
func inequality_differentIds() {
let team1 = Team(
id: "team1",
name: "Same Name",
abbreviation: "AAA",
sport: .mlb,
city: "Same City",
stadiumId: "stadium1"
)
let team2 = Team(
id: "team2",
name: "Same Name",
abbreviation: "AAA",
sport: .mlb,
city: "Same City",
stadiumId: "stadium1"
)
#expect(team1 != team2, "Teams with different ids should not be equal")
}
// MARK: - Invariant Tests
@Test("Invariant: fullName is never empty")
func invariant_fullNameNeverEmpty() {
// Team with name only
let team1 = Team(
id: "team1",
name: "Team",
abbreviation: "TM",
sport: .mlb,
city: "",
stadiumId: "stadium1"
)
#expect(!team1.fullName.isEmpty)
// Team with city and name
let team2 = Team(
id: "team2",
name: "Team",
abbreviation: "TM",
sport: .mlb,
city: "City",
stadiumId: "stadium1"
)
#expect(!team2.fullName.isEmpty)
}
// MARK: - Property Tests
@Test("Property: id is Identifiable conformance")
func property_identifiable() {
let team = Team(
id: "unique-id",
name: "Team",
abbreviation: "TM",
sport: .mlb,
city: "City",
stadiumId: "stadium1"
)
#expect(team.id == "unique-id")
}
@Test("Property: optional fields can be nil")
func property_optionalFieldsNil() {
let team = Team(
id: "team1",
name: "Team",
abbreviation: "TM",
sport: .mlb,
city: "City",
stadiumId: "stadium1",
logoURL: nil,
primaryColor: nil,
secondaryColor: nil
)
#expect(team.logoURL == nil)
#expect(team.primaryColor == nil)
#expect(team.secondaryColor == nil)
}
@Test("Property: optional fields can have values")
func property_optionalFieldsWithValues() {
let team = Team(
id: "team1",
name: "Team",
abbreviation: "TM",
sport: .mlb,
city: "City",
stadiumId: "stadium1",
logoURL: URL(string: "https://example.com/logo.png"),
primaryColor: "#FF0000",
secondaryColor: "#0000FF"
)
#expect(team.logoURL?.absoluteString == "https://example.com/logo.png")
#expect(team.primaryColor == "#FF0000")
#expect(team.secondaryColor == "#0000FF")
}
}

View File

@@ -0,0 +1,193 @@
//
// TravelSegmentTests.swift
// SportsTimeTests
//
// TDD specification tests for TravelSegment model.
//
import Testing
@testable import SportsTime
@Suite("TravelSegment")
struct TravelSegmentTests {
// MARK: - Test Data
private func makeSegment(
distanceMeters: Double,
durationSeconds: Double
) -> TravelSegment {
TravelSegment(
fromLocation: LocationInput(name: "A"),
toLocation: LocationInput(name: "B"),
travelMode: .drive,
distanceMeters: distanceMeters,
durationSeconds: durationSeconds
)
}
// MARK: - Specification Tests: Unit Conversions
@Test("distanceMiles: converts meters to miles correctly")
func distanceMiles_conversion() {
// 1 mile = 1609.344 meters
// So 1609.344 meters should be ~1 mile
let segment = makeSegment(distanceMeters: 1609.344, durationSeconds: 3600)
#expect(abs(segment.distanceMiles - 1.0) < 0.001, "1609.344 meters should be ~1 mile")
}
@Test("distanceMiles: 100 miles")
func distanceMiles_100miles() {
let metersIn100Miles = 160934.4
let segment = makeSegment(distanceMeters: metersIn100Miles, durationSeconds: 3600)
#expect(abs(segment.distanceMiles - 100.0) < 0.01)
}
@Test("distanceMiles: zero meters is zero miles")
func distanceMiles_zero() {
let segment = makeSegment(distanceMeters: 0, durationSeconds: 3600)
#expect(segment.distanceMiles == 0)
}
@Test("durationHours: converts seconds to hours correctly")
func durationHours_conversion() {
// 3600 seconds = 1 hour
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 3600)
#expect(segment.durationHours == 1.0)
}
@Test("durationHours: 2.5 hours")
func durationHours_twoAndHalf() {
// 2.5 hours = 9000 seconds
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 9000)
#expect(segment.durationHours == 2.5)
}
@Test("durationHours: zero seconds is zero hours")
func durationHours_zero() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 0)
#expect(segment.durationHours == 0)
}
// MARK: - Specification Tests: Aliases
@Test("estimatedDrivingHours is alias for durationHours")
func estimatedDrivingHours_alias() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 7200)
#expect(segment.estimatedDrivingHours == segment.durationHours)
#expect(segment.estimatedDrivingHours == 2.0)
}
@Test("estimatedDistanceMiles is alias for distanceMiles")
func estimatedDistanceMiles_alias() {
let segment = makeSegment(distanceMeters: 160934.4, durationSeconds: 3600)
#expect(segment.estimatedDistanceMiles == segment.distanceMiles)
#expect(abs(segment.estimatedDistanceMiles - 100.0) < 0.01)
}
// MARK: - Specification Tests: formattedDuration
@Test("formattedDuration: shows hours and minutes when both present")
func formattedDuration_hoursAndMinutes() {
// 2h 30m = 9000 seconds
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 9000)
#expect(segment.formattedDuration == "2h 30m")
}
@Test("formattedDuration: shows only hours when minutes are zero")
func formattedDuration_onlyHours() {
// 3h = 10800 seconds
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 10800)
#expect(segment.formattedDuration == "3h")
}
@Test("formattedDuration: shows only minutes when hours are zero")
func formattedDuration_onlyMinutes() {
// 45m = 2700 seconds
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 2700)
#expect(segment.formattedDuration == "45m")
}
@Test("formattedDuration: shows 0m for zero duration")
func formattedDuration_zero() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 0)
#expect(segment.formattedDuration == "0m")
}
// MARK: - Specification Tests: formattedDistance
@Test("formattedDistance: shows miles rounded to integer")
func formattedDistance_rounded() {
// 100.7 miles
let segment = makeSegment(distanceMeters: 162115.4, durationSeconds: 3600)
#expect(segment.formattedDistance == "101 mi")
}
// MARK: - Invariant Tests
@Test("Invariant: distanceMiles positive when meters positive")
func invariant_distanceMilesPositive() {
let testMeters: [Double] = [1, 100, 1000, 100000, 1000000]
for meters in testMeters {
let segment = makeSegment(distanceMeters: meters, durationSeconds: 3600)
#expect(segment.distanceMiles > 0, "distanceMiles should be positive for \(meters) meters")
}
}
@Test("Invariant: durationHours positive when seconds positive")
func invariant_durationHoursPositive() {
let testSeconds: [Double] = [1, 60, 3600, 7200, 36000]
for seconds in testSeconds {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: seconds)
#expect(segment.durationHours > 0, "durationHours should be positive for \(seconds) seconds")
}
}
@Test("Invariant: conversion factors are consistent")
func invariant_conversionFactorsConsistent() {
// 0.000621371 miles per meter
let meters: Double = 1000
let segment = makeSegment(distanceMeters: meters, durationSeconds: 3600)
let expectedMiles = meters * 0.000621371
#expect(segment.distanceMiles == expectedMiles)
}
// MARK: - Property Tests
@Test("Property: scenicScore defaults to 0.5")
func property_scenicScoreDefault() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 3600)
#expect(segment.scenicScore == 0.5)
}
@Test("Property: evChargingStops defaults to empty")
func property_evChargingStopsDefault() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 3600)
#expect(segment.evChargingStops.isEmpty)
}
@Test("Property: routePolyline defaults to nil")
func property_routePolylineDefault() {
let segment = makeSegment(distanceMeters: 1000, durationSeconds: 3600)
#expect(segment.routePolyline == nil)
}
}

View File

@@ -0,0 +1,415 @@
//
// TripPollTests.swift
// SportsTimeTests
//
// TDD specification tests for TripPoll, PollVote, and PollResults models.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("TripPoll")
struct TripPollTests {
// MARK: - Test Data
private func makeTrip(
name: String = "Test Trip",
stops: [TripStop] = [],
games: [String] = []
) -> Trip {
Trip(
name: name,
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: Date(),
endDate: Date().addingTimeInterval(86400 * 7)
),
stops: stops
)
}
private func makePoll(trips: [Trip] = []) -> TripPoll {
TripPoll(
title: "Test Poll",
ownerId: "owner_123",
tripSnapshots: trips
)
}
// MARK: - Specification Tests: generateShareCode
@Test("generateShareCode: returns 6 characters")
func generateShareCode_length() {
let code = TripPoll.generateShareCode()
#expect(code.count == 6)
}
@Test("generateShareCode: contains only allowed characters")
func generateShareCode_allowedChars() {
let allowedChars = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
// Generate multiple codes to test randomness
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(allowedChars.contains(char), "Character \(char) should be allowed")
}
}
}
@Test("generateShareCode: excludes ambiguous characters (O, I, L, 0, 1)")
func generateShareCode_excludesAmbiguous() {
let ambiguousChars = Set("OIL01")
// Generate many codes to test
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(!ambiguousChars.contains(char), "Character \(char) should not be in share code")
}
}
}
@Test("generateShareCode: is uppercase")
func generateShareCode_uppercase() {
for _ in 0..<50 {
let code = TripPoll.generateShareCode()
#expect(code == code.uppercased())
}
}
// MARK: - Specification Tests: computeTripHash
@Test("computeTripHash: produces deterministic hash for same trip")
func computeTripHash_deterministic() {
let trip = makeTrip(name: "Consistent Trip")
let hash1 = TripPoll.computeTripHash(trip)
let hash2 = TripPoll.computeTripHash(trip)
#expect(hash1 == hash2)
}
@Test("computeTripHash: different trips produce different hashes")
func computeTripHash_differentTrips() {
let calendar = Calendar.current
let date1 = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let date2 = calendar.date(from: DateComponents(year: 2026, month: 6, day: 16))!
let stop1 = TripStop(
stopNumber: 1,
city: "NYC",
state: "NY",
arrivalDate: date1,
departureDate: date1
)
let stop2 = TripStop(
stopNumber: 1,
city: "Boston",
state: "MA",
arrivalDate: date2,
departureDate: date2
)
let trip1 = Trip(
name: "Trip 1",
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: date1,
endDate: date1.addingTimeInterval(86400)
),
stops: [stop1]
)
let trip2 = Trip(
name: "Trip 2",
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: date2,
endDate: date2.addingTimeInterval(86400)
),
stops: [stop2]
)
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
// MARK: - Specification Tests: shareURL
@Test("shareURL: uses sportstime://poll/ prefix")
func shareURL_format() {
let poll = TripPoll(
title: "Test",
ownerId: "owner",
shareCode: "ABC123",
tripSnapshots: []
)
#expect(poll.shareURL.absoluteString == "sportstime://poll/ABC123")
}
// MARK: - Specification Tests: tripVersions
@Test("tripVersions: count equals tripSnapshots count")
func tripVersions_countMatchesSnapshots() {
let trips = [makeTrip(name: "Trip 1"), makeTrip(name: "Trip 2"), makeTrip(name: "Trip 3")]
let poll = makePoll(trips: trips)
#expect(poll.tripVersions.count == poll.tripSnapshots.count)
}
// MARK: - Invariant Tests
@Test("Invariant: shareCode is exactly 6 characters")
func invariant_shareCodeLength() {
for _ in 0..<20 {
let poll = makePoll()
#expect(poll.shareCode.count == 6)
}
}
@Test("Invariant: tripVersions.count == tripSnapshots.count")
func invariant_versionsMatchSnapshots() {
let trips = [makeTrip(), makeTrip(), makeTrip()]
let poll = makePoll(trips: trips)
#expect(poll.tripVersions.count == poll.tripSnapshots.count)
}
}
// MARK: - PollVote Tests
@Suite("PollVote")
struct PollVoteTests {
// MARK: - Specification Tests: calculateScores
@Test("calculateScores: Borda count gives tripCount points to first choice")
func calculateScores_firstChoice() {
// 3 trips, first choice gets 3 points
let scores = PollVote.calculateScores(rankings: [0, 1, 2], tripCount: 3)
#expect(scores[0] == 3) // First choice
}
@Test("calculateScores: Borda count gives decreasing points by rank")
func calculateScores_decreasingPoints() {
// Rankings: trip 2 first, trip 0 second, trip 1 third
let scores = PollVote.calculateScores(rankings: [2, 0, 1], tripCount: 3)
#expect(scores[2] == 3) // First choice gets 3
#expect(scores[0] == 2) // Second choice gets 2
#expect(scores[1] == 1) // Third choice gets 1
}
@Test("calculateScores: last choice gets 1 point")
func calculateScores_lastChoice() {
let scores = PollVote.calculateScores(rankings: [0, 1, 2], tripCount: 3)
#expect(scores[2] == 1) // Last choice
}
@Test("calculateScores: handles invalid trip index gracefully")
func calculateScores_invalidIndex() {
// Trip index 5 is out of bounds for tripCount 3
let scores = PollVote.calculateScores(rankings: [0, 1, 5], tripCount: 3)
#expect(scores[0] == 3)
#expect(scores[1] == 2)
// Index 5 is ignored since it's >= tripCount
}
@Test("calculateScores: empty rankings returns zeros")
func calculateScores_emptyRankings() {
let scores = PollVote.calculateScores(rankings: [], tripCount: 3)
#expect(scores == [0, 0, 0])
}
// MARK: - Invariant Tests
@Test("Invariant: Borda points range from 1 to tripCount")
func invariant_bordaPointsRange() {
let tripCount = 5
let rankings = [0, 1, 2, 3, 4]
let scores = PollVote.calculateScores(rankings: rankings, tripCount: tripCount)
// Each trip should have a score from 1 to tripCount
let nonZeroScores = scores.filter { $0 > 0 }
for score in nonZeroScores {
#expect(score >= 1)
#expect(score <= tripCount)
}
}
}
// MARK: - PollResults Tests
@Suite("PollResults")
struct PollResultsTests {
// MARK: - Test Data
private func makeTrip(name: String) -> Trip {
Trip(
name: name,
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: Date(),
endDate: Date().addingTimeInterval(86400 * 7)
)
)
}
private func makePoll(tripCount: Int) -> TripPoll {
let trips = (0..<tripCount).map { makeTrip(name: "Trip \($0)") }
return TripPoll(
title: "Test Poll",
ownerId: "owner",
tripSnapshots: trips
)
}
private func makeVote(pollId: UUID, rankings: [Int]) -> PollVote {
PollVote(
pollId: pollId,
odg: "voter_\(UUID())",
rankings: rankings
)
}
// MARK: - Specification Tests: voterCount
@Test("voterCount: returns number of votes")
func voterCount_returnsVoteCount() {
let poll = makePoll(tripCount: 3)
let votes = [
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
makeVote(pollId: poll.id, rankings: [1, 0, 2]),
makeVote(pollId: poll.id, rankings: [0, 2, 1]),
]
let results = PollResults(poll: poll, votes: votes)
#expect(results.voterCount == 3)
}
// MARK: - Specification Tests: tripScores
@Test("tripScores: sums all votes correctly")
func tripScores_sumsVotes() {
let poll = makePoll(tripCount: 3)
let votes = [
makeVote(pollId: poll.id, rankings: [0, 1, 2]), // Trip 0: 3, Trip 1: 2, Trip 2: 1
makeVote(pollId: poll.id, rankings: [0, 2, 1]), // Trip 0: 3, Trip 2: 2, Trip 1: 1
]
// Total: Trip 0: 6, Trip 1: 3, Trip 2: 3
let results = PollResults(poll: poll, votes: votes)
let scores = Dictionary(uniqueKeysWithValues: results.tripScores)
#expect(scores[0] == 6)
#expect(scores[1] == 3)
#expect(scores[2] == 3)
}
@Test("tripScores: sorted descending by score")
func tripScores_sortedDescending() {
let poll = makePoll(tripCount: 3)
let votes = [
makeVote(pollId: poll.id, rankings: [1, 0, 2]), // Trip 1 wins
makeVote(pollId: poll.id, rankings: [1, 2, 0]), // Trip 1 wins
]
let results = PollResults(poll: poll, votes: votes)
// First result should have highest score
let scores = results.tripScores.map { $0.score }
for i in 1..<scores.count {
#expect(scores[i] <= scores[i - 1])
}
}
@Test("tripScores: returns zeros when no votes")
func tripScores_noVotes() {
let poll = makePoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
let scores = results.tripScores
#expect(scores.count == 3)
for (_, score) in scores {
#expect(score == 0)
}
}
// MARK: - Specification Tests: maxScore
@Test("maxScore: returns highest score")
func maxScore_returnsHighest() {
let poll = makePoll(tripCount: 3)
let votes = [
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
]
// Trip 0: 6 (highest)
let results = PollResults(poll: poll, votes: votes)
#expect(results.maxScore == 6)
}
@Test("maxScore: 0 when no votes")
func maxScore_noVotes() {
let poll = makePoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
#expect(results.maxScore == 0)
}
// MARK: - Specification Tests: scorePercentage
@Test("scorePercentage: returns score/maxScore")
func scorePercentage_calculation() {
let poll = makePoll(tripCount: 2)
let votes = [
makeVote(pollId: poll.id, rankings: [0, 1]), // Trip 0: 2, Trip 1: 1
]
let results = PollResults(poll: poll, votes: votes)
#expect(results.scorePercentage(for: 0) == 1.0) // 2/2
#expect(results.scorePercentage(for: 1) == 0.5) // 1/2
}
@Test("scorePercentage: returns 0 when maxScore is 0")
func scorePercentage_maxScoreZero() {
let poll = makePoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
#expect(results.scorePercentage(for: 0) == 0)
}
// MARK: - Invariant Tests
@Test("Invariant: tripScores contains all trip indices")
func invariant_tripScoresContainsAllIndices() {
let poll = makePoll(tripCount: 4)
let votes = [makeVote(pollId: poll.id, rankings: [0, 1, 2, 3])]
let results = PollResults(poll: poll, votes: votes)
let indices = Set(results.tripScores.map { $0.tripIndex })
#expect(indices == Set(0..<4))
}
}

View File

@@ -0,0 +1,293 @@
//
// TripPreferencesTests.swift
// SportsTimeTests
//
// TDD specification tests for TripPreferences and related enums.
//
import Testing
import Foundation
import CoreLocation
@testable import SportsTime
@Suite("TripPreferences")
struct TripPreferencesTests {
// MARK: - Specification Tests: totalDriverHoursPerDay
@Test("totalDriverHoursPerDay: uses default 8 hours when maxDrivingHoursPerDriver is nil")
func totalDriverHoursPerDay_default() {
let prefs = TripPreferences(
numberOfDrivers: 1,
maxDrivingHoursPerDriver: nil
)
#expect(prefs.totalDriverHoursPerDay == 8.0)
}
@Test("totalDriverHoursPerDay: multiplies by number of drivers")
func totalDriverHoursPerDay_multipleDrivers() {
let prefs = TripPreferences(
numberOfDrivers: 2,
maxDrivingHoursPerDriver: nil
)
#expect(prefs.totalDriverHoursPerDay == 16.0) // 8 * 2
}
@Test("totalDriverHoursPerDay: uses custom maxDrivingHoursPerDriver")
func totalDriverHoursPerDay_custom() {
let prefs = TripPreferences(
numberOfDrivers: 2,
maxDrivingHoursPerDriver: 6.0
)
#expect(prefs.totalDriverHoursPerDay == 12.0) // 6 * 2
}
// MARK: - Specification Tests: effectiveTripDuration
@Test("effectiveTripDuration: uses tripDuration when set")
func effectiveTripDuration_explicit() {
let prefs = TripPreferences(
startDate: Date(),
endDate: Date().addingTimeInterval(86400 * 14),
tripDuration: 5
)
#expect(prefs.effectiveTripDuration == 5)
}
@Test("effectiveTripDuration: calculates from date range when tripDuration is nil")
func effectiveTripDuration_calculated() {
let calendar = Calendar.current
let startDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let endDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))!
let prefs = TripPreferences(
startDate: startDate,
endDate: endDate,
tripDuration: nil
)
#expect(prefs.effectiveTripDuration == 7) // 7 days between 15th and 22nd
}
@Test("effectiveTripDuration: minimum is 1")
func effectiveTripDuration_minimum() {
let date = Date()
let prefs = TripPreferences(
startDate: date,
endDate: date,
tripDuration: nil
)
#expect(prefs.effectiveTripDuration >= 1)
}
// MARK: - Invariant Tests
@Test("Invariant: totalDriverHoursPerDay > 0")
func invariant_totalDriverHoursPositive() {
// With 1 driver and default
let prefs1 = TripPreferences(numberOfDrivers: 1)
#expect(prefs1.totalDriverHoursPerDay > 0)
// With multiple drivers
let prefs2 = TripPreferences(numberOfDrivers: 3, maxDrivingHoursPerDriver: 4)
#expect(prefs2.totalDriverHoursPerDay > 0)
}
@Test("Invariant: effectiveTripDuration >= 1")
func invariant_effectiveTripDurationMinimum() {
let testCases: [Int?] = [nil, 1, 5, 10]
for duration in testCases {
let prefs = TripPreferences(tripDuration: duration)
#expect(prefs.effectiveTripDuration >= 1)
}
}
}
// MARK: - LeisureLevel Tests
@Suite("LeisureLevel")
struct LeisureLevelTests {
@Test("restDaysPerWeek: packed = 0.5")
func restDaysPerWeek_packed() {
#expect(LeisureLevel.packed.restDaysPerWeek == 0.5)
}
@Test("restDaysPerWeek: moderate = 1.5")
func restDaysPerWeek_moderate() {
#expect(LeisureLevel.moderate.restDaysPerWeek == 1.5)
}
@Test("restDaysPerWeek: relaxed = 2.5")
func restDaysPerWeek_relaxed() {
#expect(LeisureLevel.relaxed.restDaysPerWeek == 2.5)
}
@Test("maxGamesPerWeek: packed = 7")
func maxGamesPerWeek_packed() {
#expect(LeisureLevel.packed.maxGamesPerWeek == 7)
}
@Test("maxGamesPerWeek: moderate = 5")
func maxGamesPerWeek_moderate() {
#expect(LeisureLevel.moderate.maxGamesPerWeek == 5)
}
@Test("maxGamesPerWeek: relaxed = 3")
func maxGamesPerWeek_relaxed() {
#expect(LeisureLevel.relaxed.maxGamesPerWeek == 3)
}
@Test("Invariant: restDaysPerWeek increases from packed to relaxed")
func invariant_restDaysOrdering() {
#expect(LeisureLevel.packed.restDaysPerWeek < LeisureLevel.moderate.restDaysPerWeek)
#expect(LeisureLevel.moderate.restDaysPerWeek < LeisureLevel.relaxed.restDaysPerWeek)
}
@Test("Invariant: maxGamesPerWeek decreases from packed to relaxed")
func invariant_maxGamesOrdering() {
#expect(LeisureLevel.packed.maxGamesPerWeek > LeisureLevel.moderate.maxGamesPerWeek)
#expect(LeisureLevel.moderate.maxGamesPerWeek > LeisureLevel.relaxed.maxGamesPerWeek)
}
}
// MARK: - RoutePreference Tests
@Suite("RoutePreference")
struct RoutePreferenceTests {
@Test("scenicWeight: direct = 0.0")
func scenicWeight_direct() {
#expect(RoutePreference.direct.scenicWeight == 0.0)
}
@Test("scenicWeight: scenic = 1.0")
func scenicWeight_scenic() {
#expect(RoutePreference.scenic.scenicWeight == 1.0)
}
@Test("scenicWeight: balanced = 0.5")
func scenicWeight_balanced() {
#expect(RoutePreference.balanced.scenicWeight == 0.5)
}
@Test("Invariant: scenicWeight is in range [0, 1]")
func invariant_scenicWeightRange() {
for pref in RoutePreference.allCases {
#expect(pref.scenicWeight >= 0.0)
#expect(pref.scenicWeight <= 1.0)
}
}
}
// MARK: - LocationInput Tests
@Suite("LocationInput")
struct LocationInputTests {
@Test("isResolved: true when coordinate is set")
func isResolved_true() {
let input = LocationInput(
name: "New York",
coordinate: .init(latitude: 40.7, longitude: -74.0)
)
#expect(input.isResolved == true)
}
@Test("isResolved: false when coordinate is nil")
func isResolved_false() {
let input = LocationInput(name: "New York", coordinate: nil)
#expect(input.isResolved == false)
}
@Test("Invariant: isResolved equals (coordinate != nil)")
func invariant_isResolvedConsistent() {
let withCoord = LocationInput(name: "A", coordinate: .init(latitude: 0, longitude: 0))
let withoutCoord = LocationInput(name: "B", coordinate: nil)
#expect(withCoord.isResolved == (withCoord.coordinate != nil))
#expect(withoutCoord.isResolved == (withoutCoord.coordinate != nil))
}
}
// MARK: - PlanningMode Tests
@Suite("PlanningMode")
struct PlanningModeTests {
@Test("Property: all cases have displayName")
func property_allHaveDisplayName() {
for mode in PlanningMode.allCases {
#expect(!mode.displayName.isEmpty)
}
}
@Test("Property: all cases have description")
func property_allHaveDescription() {
for mode in PlanningMode.allCases {
#expect(!mode.description.isEmpty)
}
}
@Test("Property: all cases have iconName")
func property_allHaveIconName() {
for mode in PlanningMode.allCases {
#expect(!mode.iconName.isEmpty)
}
}
@Test("Property: id equals rawValue")
func property_idEqualsRawValue() {
for mode in PlanningMode.allCases {
#expect(mode.id == mode.rawValue)
}
}
}
// MARK: - TravelMode Tests
@Suite("TravelMode")
struct TravelModeTests {
@Test("Property: all cases have displayName")
func property_allHaveDisplayName() {
for mode in TravelMode.allCases {
#expect(!mode.displayName.isEmpty)
}
}
@Test("Property: all cases have iconName")
func property_allHaveIconName() {
for mode in TravelMode.allCases {
#expect(!mode.iconName.isEmpty)
}
}
}
// MARK: - LodgingType Tests
@Suite("LodgingType")
struct LodgingTypeTests {
@Test("Property: all cases have displayName")
func property_allHaveDisplayName() {
for type in LodgingType.allCases {
#expect(!type.displayName.isEmpty)
}
}
@Test("Property: all cases have iconName")
func property_allHaveIconName() {
for type in LodgingType.allCases {
#expect(!type.iconName.isEmpty)
}
}
}

View File

@@ -0,0 +1,213 @@
//
// TripStopTests.swift
// SportsTimeTests
//
// TDD specification tests for TripStop model.
//
import Testing
import Foundation
@testable import SportsTime
@Suite("TripStop")
struct TripStopTests {
// MARK: - Test Data
private func makeStop(
arrivalDate: Date,
departureDate: Date,
games: [String] = []
) -> TripStop {
TripStop(
stopNumber: 1,
city: "New York",
state: "NY",
arrivalDate: arrivalDate,
departureDate: departureDate,
games: games
)
}
// MARK: - Specification Tests: stayDuration
@Test("stayDuration: same day arrival and departure returns 1")
func stayDuration_sameDay() {
let calendar = Calendar.current
let arrivalDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let departureDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let stop = makeStop(arrivalDate: arrivalDate, departureDate: departureDate)
#expect(stop.stayDuration == 1)
}
@Test("stayDuration: 2-day stay returns 2")
func stayDuration_twoDays() {
let calendar = Calendar.current
let arrivalDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let departureDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 16))!
let stop = makeStop(arrivalDate: arrivalDate, departureDate: departureDate)
#expect(stop.stayDuration == 1, "1 day between 15th and 16th")
}
@Test("stayDuration: week-long stay")
func stayDuration_weekLong() {
let calendar = Calendar.current
let arrivalDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let departureDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 22))!
let stop = makeStop(arrivalDate: arrivalDate, departureDate: departureDate)
#expect(stop.stayDuration == 7, "7 days between 15th and 22nd")
}
@Test("stayDuration: minimum is 1 even if dates are reversed")
func stayDuration_minimumIsOne() {
let calendar = Calendar.current
let arrivalDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 20))!
let departureDate = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let stop = makeStop(arrivalDate: arrivalDate, departureDate: departureDate)
#expect(stop.stayDuration >= 1, "stayDuration should never be less than 1")
}
// MARK: - Specification Tests: hasGames
@Test("hasGames: true when games array is non-empty")
func hasGames_true() {
let now = Date()
let stop = makeStop(arrivalDate: now, departureDate: now, games: ["game1", "game2"])
#expect(stop.hasGames == true)
}
@Test("hasGames: false when games array is empty")
func hasGames_false() {
let now = Date()
let stop = makeStop(arrivalDate: now, departureDate: now, games: [])
#expect(stop.hasGames == false)
}
// MARK: - Specification Tests: formattedDateRange
@Test("formattedDateRange: single date for 1-day stay")
func formattedDateRange_singleDay() {
let calendar = Calendar.current
let date = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let stop = makeStop(arrivalDate: date, departureDate: date)
// Should show just "Jun 15"
#expect(stop.formattedDateRange == "Jun 15")
}
@Test("formattedDateRange: range for multi-day stay")
func formattedDateRange_multiDay() {
let calendar = Calendar.current
let arrival = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
let departure = calendar.date(from: DateComponents(year: 2026, month: 6, day: 18))!
let stop = makeStop(arrivalDate: arrival, departureDate: departure)
// Should show "Jun 15 - Jun 18"
#expect(stop.formattedDateRange == "Jun 15 - Jun 18")
}
// MARK: - Specification Tests: locationDescription
@Test("locationDescription: combines city and state")
func locationDescription_format() {
let stop = TripStop(
stopNumber: 1,
city: "Boston",
state: "MA",
arrivalDate: Date(),
departureDate: Date()
)
#expect(stop.locationDescription == "Boston, MA")
}
// MARK: - Invariant Tests
@Test("Invariant: stayDuration >= 1")
func invariant_stayDurationAtLeastOne() {
let calendar = Calendar.current
// Test various date combinations
let testCases: [(arrival: DateComponents, departure: 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)),
(DateComponents(year: 2026, month: 12, day: 31), DateComponents(year: 2027, month: 1, day: 2)),
]
for (arrival, departure) in testCases {
let arrivalDate = calendar.date(from: arrival)!
let departureDate = calendar.date(from: departure)!
let stop = makeStop(arrivalDate: arrivalDate, departureDate: departureDate)
#expect(stop.stayDuration >= 1, "stayDuration should be at least 1")
}
}
@Test("Invariant: hasGames equals !games.isEmpty")
func invariant_hasGamesConsistent() {
let now = Date()
let stopWithGames = makeStop(arrivalDate: now, departureDate: now, games: ["game1"])
#expect(stopWithGames.hasGames == !stopWithGames.games.isEmpty)
let stopWithoutGames = makeStop(arrivalDate: now, departureDate: now, games: [])
#expect(stopWithoutGames.hasGames == !stopWithoutGames.games.isEmpty)
}
// MARK: - Property Tests
@Test("Property: isRestDay defaults to false")
func property_isRestDayDefault() {
let now = Date()
let stop = makeStop(arrivalDate: now, departureDate: now)
#expect(stop.isRestDay == false)
}
@Test("Property: isRestDay can be set to true")
func property_isRestDayTrue() {
let stop = TripStop(
stopNumber: 1,
city: "City",
state: "ST",
arrivalDate: Date(),
departureDate: Date(),
isRestDay: true
)
#expect(stop.isRestDay == true)
}
@Test("Property: optional fields can be nil")
func property_optionalFieldsNil() {
let stop = TripStop(
stopNumber: 1,
city: "City",
state: "ST",
coordinate: nil,
arrivalDate: Date(),
departureDate: Date(),
stadium: nil,
lodging: nil,
notes: nil
)
#expect(stop.coordinate == nil)
#expect(stop.stadium == nil)
#expect(stop.lodging == nil)
#expect(stop.notes == nil)
}
}

View File

@@ -0,0 +1,416 @@
//
// 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")
}
}