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:
364
SportsTimeTests/Domain/AchievementDefinitionsTests.swift
Normal file
364
SportsTimeTests/Domain/AchievementDefinitionsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
194
SportsTimeTests/Domain/AnySportTests.swift
Normal file
194
SportsTimeTests/Domain/AnySportTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
317
SportsTimeTests/Domain/DivisionTests.swift
Normal file
317
SportsTimeTests/Domain/DivisionTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
211
SportsTimeTests/Domain/GameTests.swift
Normal file
211
SportsTimeTests/Domain/GameTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
572
SportsTimeTests/Domain/ProgressTests.swift
Normal file
572
SportsTimeTests/Domain/ProgressTests.swift
Normal 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%")
|
||||
}
|
||||
}
|
||||
164
SportsTimeTests/Domain/RegionTests.swift
Normal file
164
SportsTimeTests/Domain/RegionTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
232
SportsTimeTests/Domain/StadiumTests.swift
Normal file
232
SportsTimeTests/Domain/StadiumTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
202
SportsTimeTests/Domain/TeamTests.swift
Normal file
202
SportsTimeTests/Domain/TeamTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
193
SportsTimeTests/Domain/TravelSegmentTests.swift
Normal file
193
SportsTimeTests/Domain/TravelSegmentTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
415
SportsTimeTests/Domain/TripPollTests.swift
Normal file
415
SportsTimeTests/Domain/TripPollTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
293
SportsTimeTests/Domain/TripPreferencesTests.swift
Normal file
293
SportsTimeTests/Domain/TripPreferencesTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
213
SportsTimeTests/Domain/TripStopTests.swift
Normal file
213
SportsTimeTests/Domain/TripStopTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
416
SportsTimeTests/Domain/TripTests.swift
Normal file
416
SportsTimeTests/Domain/TripTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user