From 8162b4a029669b457d7e856a913495cbe673170b Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 14:07:41 -0600 Subject: [PATCH] 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 --- .../Domain/AchievementDefinitions.swift | 13 + SportsTime/Core/Models/Domain/AnySport.swift | 9 + SportsTime/Core/Models/Domain/Division.swift | 12 + .../Core/Models/Domain/DynamicSport.swift | 10 + SportsTime/Core/Models/Domain/Game.swift | 12 + SportsTime/Core/Models/Domain/Progress.swift | 13 + SportsTime/Core/Models/Domain/Region.swift | 10 + SportsTime/Core/Models/Domain/Sport.swift | 13 + SportsTime/Core/Models/Domain/Stadium.swift | 13 + SportsTime/Core/Models/Domain/Team.swift | 11 + .../Core/Models/Domain/TravelSegment.swift | 12 + SportsTime/Core/Models/Domain/Trip.swift | 14 + SportsTime/Core/Models/Domain/TripPoll.swift | 15 + .../Core/Models/Domain/TripPreferences.swift | 15 + SportsTime/Core/Models/Domain/TripStop.swift | 11 + .../Export/Sharing/ShareableContent.swift | 4 +- .../Planning/Engine/GameDAGRouter.swift | 30 + .../Planning/Engine/ItineraryBuilder.swift | 28 + SportsTime/Planning/Engine/RouteFilters.swift | 101 +- .../Planning/Engine/ScenarioAPlanner.swift | 16 + .../Planning/Engine/ScenarioBPlanner.swift | 25 + .../Planning/Engine/ScenarioCPlanner.swift | 21 + .../Planning/Engine/ScenarioDPlanner.swift | 16 + .../Planning/Engine/ScenarioPlanner.swift | 33 +- .../Planning/Engine/TravelEstimator.swift | 110 +- .../Planning/Engine/TripPlanningEngine.swift | 13 + .../Planning/Models/PlanningModels.swift | 39 +- .../Data/CanonicalSportTests.swift | 109 -- .../Domain/AchievementDefinitionsTests.swift | 364 +++++ SportsTimeTests/Domain/AnySportTests.swift | 194 +++ SportsTimeTests/Domain/DivisionTests.swift | 317 ++++ .../Domain/DynamicSportTests.swift | 247 +-- SportsTimeTests/Domain/GameTests.swift | 211 +++ SportsTimeTests/Domain/PollTests.swift | 309 ---- SportsTimeTests/Domain/ProgressTests.swift | 572 +++++++ SportsTimeTests/Domain/RegionTests.swift | 164 ++ SportsTimeTests/Domain/SportTests.swift | 212 ++- SportsTimeTests/Domain/StadiumTests.swift | 232 +++ SportsTimeTests/Domain/TeamTests.swift | 202 +++ .../Domain/TravelSegmentTests.swift | 193 +++ SportsTimeTests/Domain/TripPollTests.swift | 415 +++++ .../Domain/TripPreferencesTests.swift | 293 ++++ SportsTimeTests/Domain/TripStopTests.swift | 213 +++ SportsTimeTests/Domain/TripTests.swift | 416 +++++ .../Export/MapSnapshotServiceTests.swift | 67 + .../Export/PDFGeneratorTests.swift | 185 +++ .../Export/POISearchServiceTests.swift | 203 +++ .../Export/ShareableContentTests.swift | 274 ++++ .../Progress/GamesHistoryViewModelTests.swift | 115 -- .../Progress/ProgressMapViewTests.swift | 48 - .../Features/Progress/VisitListTests.swift | 116 -- .../Fixtures/FixtureGenerator.swift | 490 ------ .../Helpers/BruteForceRouteVerifier.swift | 305 ---- SportsTimeTests/Helpers/MockServices.swift | 102 ++ SportsTimeTests/Helpers/TestConstants.swift | 87 - SportsTimeTests/Helpers/TestFixtures.swift | 467 ++++++ .../Loading/LoadingPlaceholderTests.swift | 37 - .../Loading/LoadingSheetTests.swift | 28 - .../Loading/LoadingSpinnerTests.swift | 47 - .../Mocks/MockAppDataProvider.swift | 319 ---- .../Mocks/MockCloudKitService.swift | 294 ---- SportsTimeTests/Mocks/MockData+Polls.swift | 130 -- .../Mocks/MockLocationService.swift | 296 ---- .../Planning/ConcurrencyTests.swift | 241 --- SportsTimeTests/Planning/EdgeCaseTests.swift | 618 ------- .../Planning/GameDAGRouterScaleTests.swift | 394 ----- .../Planning/GameDAGRouterTests.swift | 1115 ++++++------- .../Planning/ItineraryBuilderTests.swift | 545 ++++--- .../Planning/PlanningModelsTests.swift | 487 ++++++ .../Planning/RouteFiltersTests.swift | 796 ++++++---- .../Planning/ScenarioAPlannerTests.swift | 1007 +++++------- .../Planning/ScenarioBPlannerTests.swift | 761 ++++----- .../Planning/ScenarioCPlannerTests.swift | 1030 +++++------- .../Planning/ScenarioDPlannerTests.swift | 1414 +++++------------ .../ScenarioPlannerFactoryTests.swift | 296 ++++ .../Planning/TravelEstimatorTests.swift | 518 ++++-- .../Planning/TripPlanningEngineTests.swift | 836 ++-------- SportsTimeTests/PlanningTipsTests.swift | 38 - .../Progress/AchievementEngineTests.swift | 476 ------ .../Services/AchievementEngineTests.swift | 268 ++++ .../Services/DataProviderTests.swift | 48 + .../Services/DeepLinkHandlerTests.swift | 166 ++ .../Services/EVChargingServiceTests.swift | 210 +++ .../Services/FreeScoreAPITests.swift | 375 +++++ .../Services/GameMatcherTests.swift | 315 ++++ .../Services/HistoricalGameScraperTests.swift | 151 ++ .../Services/LocationServiceTests.swift | 305 ++++ .../PhotoMetadataExtractorTests.swift | 137 ++ .../Services/PollServiceTests.swift | 114 ++ .../Services/RateLimiterTests.swift | 124 ++ .../RouteDescriptionGeneratorTests.swift | 256 +++ .../Services/ScoreResolutionCacheTests.swift | 182 +++ .../StadiumProximityMatcherTests.swift | 340 ++++ .../SuggestedTripsGeneratorTests.swift | 255 +++ .../Services/VisitPhotoServiceTests.swift | 108 ++ SportsTimeTests/SportsTimeTests.swift | 83 - SportsTimeTests/Store/ProFeatureTests.swift | 35 - SportsTimeTests/Store/ProGateTests.swift | 16 - SportsTimeTests/Store/StoreErrorTests.swift | 16 - SportsTimeTests/Store/StoreManagerTests.swift | 36 - .../Trip/TripWizardViewModelTests.swift | 186 --- .../TripOptionsGroupingTests.swift | 102 -- 102 files changed, 13409 insertions(+), 9883 deletions(-) delete mode 100644 SportsTimeTests/Data/CanonicalSportTests.swift create mode 100644 SportsTimeTests/Domain/AchievementDefinitionsTests.swift create mode 100644 SportsTimeTests/Domain/AnySportTests.swift create mode 100644 SportsTimeTests/Domain/DivisionTests.swift create mode 100644 SportsTimeTests/Domain/GameTests.swift delete mode 100644 SportsTimeTests/Domain/PollTests.swift create mode 100644 SportsTimeTests/Domain/ProgressTests.swift create mode 100644 SportsTimeTests/Domain/RegionTests.swift create mode 100644 SportsTimeTests/Domain/StadiumTests.swift create mode 100644 SportsTimeTests/Domain/TeamTests.swift create mode 100644 SportsTimeTests/Domain/TravelSegmentTests.swift create mode 100644 SportsTimeTests/Domain/TripPollTests.swift create mode 100644 SportsTimeTests/Domain/TripPreferencesTests.swift create mode 100644 SportsTimeTests/Domain/TripStopTests.swift create mode 100644 SportsTimeTests/Domain/TripTests.swift create mode 100644 SportsTimeTests/Export/MapSnapshotServiceTests.swift create mode 100644 SportsTimeTests/Export/PDFGeneratorTests.swift create mode 100644 SportsTimeTests/Export/POISearchServiceTests.swift create mode 100644 SportsTimeTests/Export/ShareableContentTests.swift delete mode 100644 SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift delete mode 100644 SportsTimeTests/Features/Progress/ProgressMapViewTests.swift delete mode 100644 SportsTimeTests/Features/Progress/VisitListTests.swift delete mode 100644 SportsTimeTests/Fixtures/FixtureGenerator.swift delete mode 100644 SportsTimeTests/Helpers/BruteForceRouteVerifier.swift create mode 100644 SportsTimeTests/Helpers/MockServices.swift delete mode 100644 SportsTimeTests/Helpers/TestConstants.swift create mode 100644 SportsTimeTests/Helpers/TestFixtures.swift delete mode 100644 SportsTimeTests/Loading/LoadingPlaceholderTests.swift delete mode 100644 SportsTimeTests/Loading/LoadingSheetTests.swift delete mode 100644 SportsTimeTests/Loading/LoadingSpinnerTests.swift delete mode 100644 SportsTimeTests/Mocks/MockAppDataProvider.swift delete mode 100644 SportsTimeTests/Mocks/MockCloudKitService.swift delete mode 100644 SportsTimeTests/Mocks/MockData+Polls.swift delete mode 100644 SportsTimeTests/Mocks/MockLocationService.swift delete mode 100644 SportsTimeTests/Planning/ConcurrencyTests.swift delete mode 100644 SportsTimeTests/Planning/EdgeCaseTests.swift delete mode 100644 SportsTimeTests/Planning/GameDAGRouterScaleTests.swift create mode 100644 SportsTimeTests/Planning/PlanningModelsTests.swift create mode 100644 SportsTimeTests/Planning/ScenarioPlannerFactoryTests.swift delete mode 100644 SportsTimeTests/PlanningTipsTests.swift delete mode 100644 SportsTimeTests/Progress/AchievementEngineTests.swift create mode 100644 SportsTimeTests/Services/AchievementEngineTests.swift create mode 100644 SportsTimeTests/Services/DataProviderTests.swift create mode 100644 SportsTimeTests/Services/DeepLinkHandlerTests.swift create mode 100644 SportsTimeTests/Services/EVChargingServiceTests.swift create mode 100644 SportsTimeTests/Services/FreeScoreAPITests.swift create mode 100644 SportsTimeTests/Services/GameMatcherTests.swift create mode 100644 SportsTimeTests/Services/HistoricalGameScraperTests.swift create mode 100644 SportsTimeTests/Services/LocationServiceTests.swift create mode 100644 SportsTimeTests/Services/PhotoMetadataExtractorTests.swift create mode 100644 SportsTimeTests/Services/PollServiceTests.swift create mode 100644 SportsTimeTests/Services/RateLimiterTests.swift create mode 100644 SportsTimeTests/Services/RouteDescriptionGeneratorTests.swift create mode 100644 SportsTimeTests/Services/ScoreResolutionCacheTests.swift create mode 100644 SportsTimeTests/Services/StadiumProximityMatcherTests.swift create mode 100644 SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift create mode 100644 SportsTimeTests/Services/VisitPhotoServiceTests.swift delete mode 100644 SportsTimeTests/SportsTimeTests.swift delete mode 100644 SportsTimeTests/Store/ProFeatureTests.swift delete mode 100644 SportsTimeTests/Store/ProGateTests.swift delete mode 100644 SportsTimeTests/Store/StoreErrorTests.swift delete mode 100644 SportsTimeTests/Store/StoreManagerTests.swift delete mode 100644 SportsTimeTests/Trip/TripWizardViewModelTests.swift delete mode 100644 SportsTimeTests/TripOptionsGroupingTests.swift diff --git a/SportsTime/Core/Models/Domain/AchievementDefinitions.swift b/SportsTime/Core/Models/Domain/AchievementDefinitions.swift index d02f70b..a76cbfa 100644 --- a/SportsTime/Core/Models/Domain/AchievementDefinitions.swift +++ b/SportsTime/Core/Models/Domain/AchievementDefinitions.swift @@ -4,6 +4,19 @@ // // Registry of all achievement types and their requirements. // +// - Expected Behavior: +// - AchievementRegistry.all is sorted by sortOrder ascending +// - achievement(byId:) returns nil for unknown IDs +// - achievements(forCategory:) filters by exact category match +// - achievements(forSport:) includes sport-specific AND nil-sport (cross-sport) achievements +// - divisionAchievements(forSport:) filters to category=.division AND sport +// +// - Invariants: +// - All achievement IDs are unique +// - Division achievements have non-nil divisionId +// - Conference achievements have non-nil conferenceId +// - AchievementDefinition equality is based solely on id +// import Foundation import SwiftUI diff --git a/SportsTime/Core/Models/Domain/AnySport.swift b/SportsTime/Core/Models/Domain/AnySport.swift index 1d17c91..04ceadd 100644 --- a/SportsTime/Core/Models/Domain/AnySport.swift +++ b/SportsTime/Core/Models/Domain/AnySport.swift @@ -4,6 +4,15 @@ // // Protocol unifying Sport enum and DynamicSport for interchangeable use. // +// - Expected Behavior: +// - isInSeason(for:) default implementation handles wrap-around seasons +// - For normal seasons (start <= end): month >= start AND month <= end +// - For wrap-around seasons (start > end): month >= start OR month <= end +// +// - Invariants: +// - isInSeason returns true for exactly the months in the season range +// - seasonMonths start and end are always 1-12 +// import SwiftUI diff --git a/SportsTime/Core/Models/Domain/Division.swift b/SportsTime/Core/Models/Domain/Division.swift index 2c89bb3..74885e4 100644 --- a/SportsTime/Core/Models/Domain/Division.swift +++ b/SportsTime/Core/Models/Domain/Division.swift @@ -4,6 +4,18 @@ // // Domain model for league structure: divisions and conferences. // +// - Expected Behavior: +// - LeagueStructure.divisions(for:) returns divisions for MLB, NBA, NHL; empty for others +// - LeagueStructure.conferences(for:) returns conferences filtered by sport +// - LeagueStructure.division(byId:) finds division across all sports +// - LeagueStructure.conference(byId:) finds conference across all sports +// - stadiumCount returns 30 for MLB, 30 for NBA, 32 for NHL, 0 for others +// +// - Invariants: +// - Division.teamCount == teamCanonicalIds.count +// - MLB has 6 divisions (3 per league), NBA has 6, NHL has 4 +// - Each conference contains valid division IDs +// import Foundation diff --git a/SportsTime/Core/Models/Domain/DynamicSport.swift b/SportsTime/Core/Models/Domain/DynamicSport.swift index 14e2c5f..f5ce870 100644 --- a/SportsTime/Core/Models/Domain/DynamicSport.swift +++ b/SportsTime/Core/Models/Domain/DynamicSport.swift @@ -4,6 +4,16 @@ // // Domain model for CloudKit-defined sports. // +// - Expected Behavior: +// - sportId returns id (from AnySport protocol) +// - color converts colorHex to SwiftUI Color +// - seasonMonths returns (seasonStartMonth, seasonEndMonth) +// - isInSeason uses default AnySport implementation +// +// - Invariants: +// - sportId == id (they are aliases) +// - seasonStartMonth and seasonEndMonth are 1-12 +// import SwiftUI diff --git a/SportsTime/Core/Models/Domain/Game.swift b/SportsTime/Core/Models/Domain/Game.swift index 8fce1ca..32303d6 100644 --- a/SportsTime/Core/Models/Domain/Game.swift +++ b/SportsTime/Core/Models/Domain/Game.swift @@ -2,6 +2,18 @@ // Game.swift // SportsTime // +// Domain model for a scheduled game. +// +// - Expected Behavior: +// - gameDate returns the start of day (midnight) for dateTime +// - startTime is an alias for dateTime +// - Two games are equal if and only if their ids match +// +// - Invariants: +// - gameDate is always at midnight (00:00:00) local time +// - startTime == dateTime (they are aliases) +// - Equality is based solely on id, not other fields +// import Foundation diff --git a/SportsTime/Core/Models/Domain/Progress.swift b/SportsTime/Core/Models/Domain/Progress.swift index ee24978..30b81ec 100644 --- a/SportsTime/Core/Models/Domain/Progress.swift +++ b/SportsTime/Core/Models/Domain/Progress.swift @@ -4,6 +4,19 @@ // // Domain models for tracking stadium visit progress and achievements. // +// - Expected Behavior: +// - completionPercentage = (visited / total) * 100, returns 0 when total is 0 +// - progressFraction = visited / total, returns 0 when total is 0 +// - isComplete = total > 0 AND visited >= total +// - StadiumVisitStatus.latestVisit returns visit with max date +// - StadiumVisitStatus.firstVisit returns visit with min date +// +// - Invariants: +// - completionPercentage is in range [0, 100] +// - progressFraction is in range [0, 1] +// - isComplete can only be true if totalStadiums > 0 +// - visitCount == 0 for notVisited status +// import Foundation import SwiftUI diff --git a/SportsTime/Core/Models/Domain/Region.swift b/SportsTime/Core/Models/Domain/Region.swift index da34f86..a8625c8 100644 --- a/SportsTime/Core/Models/Domain/Region.swift +++ b/SportsTime/Core/Models/Domain/Region.swift @@ -4,6 +4,16 @@ // // Geographic regions for trip classification. // +// - Expected Behavior: +// - classify(longitude:) returns .east for longitude > -85 +// - classify(longitude:) returns .central for longitude in -110...-85 (inclusive) +// - classify(longitude:) returns .west for longitude < -110 +// - Boundary values: -85 → central, -110 → central +// +// - Invariants: +// - Every longitude maps to exactly one region (east, central, or west) +// - crossCountry is never returned by classify() (it's for trip categorization) +// import Foundation diff --git a/SportsTime/Core/Models/Domain/Sport.swift b/SportsTime/Core/Models/Domain/Sport.swift index 58c7b6a..69d2fe8 100644 --- a/SportsTime/Core/Models/Domain/Sport.swift +++ b/SportsTime/Core/Models/Domain/Sport.swift @@ -2,6 +2,19 @@ // Sport.swift // SportsTime // +// Enumeration of supported sports with season information. +// +// - Expected Behavior: +// - isInSeason(for:) returns true when month falls within seasonMonths range +// - For wrap-around seasons (start > end), month is in season if >= start OR <= end +// - For normal seasons (start <= end), month is in season if >= start AND <= end +// - supported returns all 7 sports in a consistent order +// +// - Invariants: +// - Each sport has exactly one season range (start, end) +// - seasonMonths values are always 1-12 (valid months) +// - All CaseIterable cases match the supported array +// import Foundation import SwiftUI diff --git a/SportsTime/Core/Models/Domain/Stadium.swift b/SportsTime/Core/Models/Domain/Stadium.swift index a8a7412..0b83fe7 100644 --- a/SportsTime/Core/Models/Domain/Stadium.swift +++ b/SportsTime/Core/Models/Domain/Stadium.swift @@ -2,6 +2,19 @@ // Stadium.swift // SportsTime // +// Domain model for a sports venue. +// +// - Expected Behavior: +// - region is derived from longitude using Region.classify() +// - distance(to:) returns meters between two stadiums +// - coordinate returns CLLocationCoordinate2D from latitude/longitude +// - Two stadiums are equal if and only if their ids match +// +// - Invariants: +// - location and coordinate always match latitude/longitude +// - region is always consistent with Region.classify(longitude:) +// - Equality is based solely on id +// import Foundation import CoreLocation diff --git a/SportsTime/Core/Models/Domain/Team.swift b/SportsTime/Core/Models/Domain/Team.swift index 6779a22..023dd18 100644 --- a/SportsTime/Core/Models/Domain/Team.swift +++ b/SportsTime/Core/Models/Domain/Team.swift @@ -2,6 +2,17 @@ // Team.swift // SportsTime // +// Domain model for a sports team. +// +// - Expected Behavior: +// - fullName returns "city name" when city is non-empty +// - fullName returns just name when city is empty +// - Two teams are equal if and only if their ids match +// +// - Invariants: +// - fullName is never empty (name is required) +// - Equality is based solely on id +// import Foundation diff --git a/SportsTime/Core/Models/Domain/TravelSegment.swift b/SportsTime/Core/Models/Domain/TravelSegment.swift index 3053abc..8957311 100644 --- a/SportsTime/Core/Models/Domain/TravelSegment.swift +++ b/SportsTime/Core/Models/Domain/TravelSegment.swift @@ -2,6 +2,18 @@ // TravelSegment.swift // SportsTime // +// Domain model for travel between trip stops. +// +// - Expected Behavior: +// - distanceMiles = distanceMeters * 0.000621371 +// - durationHours = durationSeconds / 3600 +// - estimatedDrivingHours and estimatedDistanceMiles are aliases +// - formattedDuration shows "Xh Ym" format, dropping zero components +// +// - Invariants: +// - distanceMiles is always positive when distanceMeters is positive +// - durationHours is always positive when durationSeconds is positive +// import Foundation import CoreLocation diff --git a/SportsTime/Core/Models/Domain/Trip.swift b/SportsTime/Core/Models/Domain/Trip.swift index 7a58aa3..0c01b27 100644 --- a/SportsTime/Core/Models/Domain/Trip.swift +++ b/SportsTime/Core/Models/Domain/Trip.swift @@ -2,6 +2,20 @@ // Trip.swift // SportsTime // +// Domain model for a planned sports trip. +// +// - Expected Behavior: +// - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity +// - Last activity day is departure - 1 (departure is when you leave) +// - tripDuration is max(1, days between first arrival and last departure + 1) +// - cities returns deduplicated city list preserving visit order +// - displayName uses " → " separator between cities +// +// - Invariants: +// - tripDuration >= 1 (minimum 1 day) +// - cities has no duplicates +// - itineraryDays() dayNumber starts at 1 and increments +// import Foundation diff --git a/SportsTime/Core/Models/Domain/TripPoll.swift b/SportsTime/Core/Models/Domain/TripPoll.swift index 85b1610..20d483b 100644 --- a/SportsTime/Core/Models/Domain/TripPoll.swift +++ b/SportsTime/Core/Models/Domain/TripPoll.swift @@ -2,6 +2,21 @@ // TripPoll.swift // SportsTime // +// Domain models for group trip polling and voting. +// +// - Expected Behavior: +// - generateShareCode() returns 6 uppercase alphanumeric characters (no O/I/L/0/1) +// - computeTripHash() produces deterministic hash from stops, games, and dates +// - PollVote.calculateScores uses Borda count: points = tripCount - rank +// - PollResults.tripScores sums all votes and sorts descending by score +// - scorePercentage returns score/maxScore, 0 when maxScore is 0 +// +// - Invariants: +// - shareCode is exactly 6 characters +// - tripVersions.count == tripSnapshots.count +// - Borda points range from 1 (last place) to tripCount (first place) +// - tripScores contains all trip indices +// import Foundation diff --git a/SportsTime/Core/Models/Domain/TripPreferences.swift b/SportsTime/Core/Models/Domain/TripPreferences.swift index a428887..f7a7bb2 100644 --- a/SportsTime/Core/Models/Domain/TripPreferences.swift +++ b/SportsTime/Core/Models/Domain/TripPreferences.swift @@ -2,6 +2,21 @@ // TripPreferences.swift // SportsTime // +// User preferences for trip planning. +// +// - Expected Behavior: +// - totalDriverHoursPerDay = (maxDrivingHoursPerDriver ?? 8.0) * numberOfDrivers +// - effectiveTripDuration uses tripDuration if set, otherwise calculates from date range +// - effectiveTripDuration is at least 1 day +// - LeisureLevel.restDaysPerWeek: packed=0.5, moderate=1.5, relaxed=2.5 +// - LeisureLevel.maxGamesPerWeek: packed=7, moderate=5, relaxed=3 +// - RoutePreference.scenicWeight: direct=0.0, scenic=1.0, balanced=0.5 +// +// - Invariants: +// - totalDriverHoursPerDay > 0 (numberOfDrivers >= 1) +// - effectiveTripDuration >= 1 +// - LocationInput.isResolved == (coordinate != nil) +// import Foundation import CoreLocation diff --git a/SportsTime/Core/Models/Domain/TripStop.swift b/SportsTime/Core/Models/Domain/TripStop.swift index 650d5ee..acf3758 100644 --- a/SportsTime/Core/Models/Domain/TripStop.swift +++ b/SportsTime/Core/Models/Domain/TripStop.swift @@ -2,6 +2,17 @@ // TripStop.swift // SportsTime // +// Domain model for a stop on a trip itinerary. +// +// - Expected Behavior: +// - stayDuration returns days between arrival and departure (minimum 1) +// - Same-day arrival and departure returns 1 +// - formattedDateRange shows single date for 1-day stay, range for multi-day +// +// - Invariants: +// - stayDuration >= 1 (never zero or negative) +// - hasGames == !games.isEmpty +// import Foundation import CoreLocation diff --git a/SportsTime/Export/Sharing/ShareableContent.swift b/SportsTime/Export/Sharing/ShareableContent.swift index 3c3f98e..93259a0 100644 --- a/SportsTime/Export/Sharing/ShareableContent.swift +++ b/SportsTime/Export/Sharing/ShareableContent.swift @@ -149,8 +149,8 @@ enum ShareError: Error, LocalizedError { enum ShareCardDimensions { static let cardSize = CGSize(width: 1080, height: 1920) - static let mapSnapshotSize = CGSize(width: 1000, height: 500) - static let routeMapSize = CGSize(width: 1000, height: 600) + static let mapSnapshotSize = CGSize(width: 960, height: 480) // Must fit within cardSize.width - 2*padding + static let routeMapSize = CGSize(width: 960, height: 576) // Must fit within cardSize.width - 2*padding static let padding: CGFloat = 60 static let headerHeight: CGFloat = 120 static let footerHeight: CGFloat = 100 diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 2968e55..453bc1c 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -23,6 +23,26 @@ import Foundation import CoreLocation +/// DAG-based route finder for multi-game trips. +/// +/// Finds time-respecting paths through a graph of games with driving feasibility +/// and multi-dimensional diversity selection. +/// +/// - Expected Behavior: +/// - Empty games → empty result +/// - Single game → [[game]] if no anchors or game is anchor +/// - Two games → [[sorted]] if transition feasible and anchors satisfied +/// - All routes are chronologically ordered +/// - All routes respect driving constraints +/// - Anchor games MUST appear in all returned routes +/// - allowRepeatCities=false → no city appears twice in same route +/// - Returns diverse set spanning games, cities, miles, and duration +/// +/// - Invariants: +/// - All returned routes have games in chronological order +/// - All games in a route have feasible transitions between consecutive games +/// - Maximum 75 routes returned (maxOptions) +/// - Routes with anchor games include ALL anchor games enum GameDAGRouter { // MARK: - Configuration @@ -104,6 +124,16 @@ enum GameDAGRouter { /// /// - Returns: Array of diverse route options /// + /// - Expected Behavior: + /// - Empty games → empty result + /// - Single game with no anchors → [[game]] + /// - Single game that is the anchor → [[game]] + /// - Single game not matching anchor → [] + /// - Two feasible games → [[game1, game2]] (chronological order) + /// - Two infeasible games (no anchors) → [[game1], [game2]] (separate routes) + /// - Two infeasible games (with anchors) → [] (can't satisfy) + /// - anchorGameIds must be subset of any returned route + /// - allowRepeatCities=false filters routes with duplicate cities static func findRoutes( games: [Game], stadiums: [String: Stadium], diff --git a/SportsTime/Planning/Engine/ItineraryBuilder.swift b/SportsTime/Planning/Engine/ItineraryBuilder.swift index 96cfd04..e3a4e37 100644 --- a/SportsTime/Planning/Engine/ItineraryBuilder.swift +++ b/SportsTime/Planning/Engine/ItineraryBuilder.swift @@ -9,6 +9,11 @@ import Foundation /// Result of building an itinerary from stops. +/// +/// - Invariants: +/// - travelSegments.count == stops.count - 1 (or 0 if stops.count <= 1) +/// - totalDrivingHours >= 0 +/// - totalDistanceMiles >= 0 struct BuiltItinerary { let stops: [ItineraryStop] let travelSegments: [TravelSegment] @@ -17,6 +22,20 @@ struct BuiltItinerary { } /// Shared logic for building itineraries across all scenario planners. +/// +/// Connects stops with travel segments and validates feasibility. +/// +/// - Expected Behavior: +/// - Empty stops → empty itinerary (no travel) +/// - Single stop → single-stop itinerary (no travel) +/// - Multiple stops → segments between each consecutive pair +/// - Infeasible segment (exceeds driving limits) → returns nil +/// - Validator returns false → returns nil +/// +/// - Invariants: +/// - travelSegments.count == stops.count - 1 for successful builds +/// - All segments pass TravelEstimator feasibility check +/// - All segments pass optional custom validator enum ItineraryBuilder { /// Validation that can be performed on each travel segment. @@ -40,6 +59,15 @@ enum ItineraryBuilder { /// /// - Returns: Built itinerary if successful, nil if any segment fails /// + /// - Expected Behavior: + /// - Empty stops → BuiltItinerary with empty segments + /// - Single stop → BuiltItinerary with empty segments + /// - Two stops → one segment connecting them + /// - N stops → N-1 segments + /// - TravelEstimator returns nil → returns nil + /// - Validator returns false → returns nil + /// - totalDrivingHours = sum of segment.estimatedDrivingHours + /// - totalDistanceMiles = sum of segment.estimatedDistanceMiles static func build( stops: [ItineraryStop], constraints: DrivingConstraints, diff --git a/SportsTime/Planning/Engine/RouteFilters.swift b/SportsTime/Planning/Engine/RouteFilters.swift index e3d4873..c3cf54d 100644 --- a/SportsTime/Planning/Engine/RouteFilters.swift +++ b/SportsTime/Planning/Engine/RouteFilters.swift @@ -9,12 +9,32 @@ import Foundation import CoreLocation +/// Filters for itinerary options and trips. +/// +/// Provides post-planning filtering to enforce user preferences like +/// repeat city rules, sport filtering, and date range filtering. +/// +/// - Invariants: +/// - Filtering is idempotent: filter(filter(x)) == filter(x) +/// - Empty input → empty output (preservation) +/// - Filtering never adds items, only removes +/// - Filter order doesn't matter for same criteria enum RouteFilters { // MARK: - Repeat Cities Filter - /// Filter itinerary options that violate repeat city rules. - /// When allowRepeatCities=false, each city must be visited on exactly ONE day. + /// Filters itinerary options based on repeat city rules. + /// + /// - Parameters: + /// - options: Itinerary options to filter + /// - allow: If true, returns all options; if false, removes options visiting any city on multiple days + /// - Returns: Filtered options + /// + /// - Expected Behavior: + /// - allow=true → returns all options unchanged + /// - allow=false → removes options where any city is visited on different calendar days + /// - Empty options → returns empty array + /// - Same city on same day (multiple stops) is allowed static func filterRepeatCities( _ options: [ItineraryOption], allow: Bool @@ -23,7 +43,16 @@ enum RouteFilters { return options.filter { !hasRepeatCityViolation($0) } } - /// Check if an itinerary visits any city on multiple days. + /// Checks if an itinerary visits any city on multiple calendar days. + /// + /// - Parameter option: Itinerary option to check + /// - Returns: true if any city appears on more than one distinct calendar day + /// + /// - Expected Behavior: + /// - Single stop → false + /// - Multiple stops, different cities → false + /// - Same city, same day → false (allowed) + /// - Same city, different days → true (violation) static func hasRepeatCityViolation(_ option: ItineraryOption) -> Bool { let calendar = Calendar.current var cityDays: [String: Set] = [:] @@ -38,7 +67,16 @@ enum RouteFilters { return cityDays.values.contains(where: { $0.count > 1 }) } - /// Get cities that are visited on multiple days (for error reporting). + /// Gets cities that are visited on multiple days across all options. + /// + /// - Parameter options: Itinerary options to analyze + /// - Returns: Sorted array of city names that appear on multiple days + /// + /// - Expected Behavior: + /// - Empty options → empty array + /// - No violations → empty array + /// - Returns unique city names, alphabetically sorted + /// - Aggregates across all options static func findRepeatCities(in options: [ItineraryOption]) -> [String] { var violatingCities = Set() let calendar = Calendar.current @@ -59,7 +97,18 @@ enum RouteFilters { // MARK: - Trip List Filters - /// Filter trips by sport. Returns trips containing ANY of the specified sports. + /// Filters trips by sport. + /// + /// - Parameters: + /// - trips: Trips to filter + /// - sports: Sports to match + /// - Returns: Trips containing ANY of the specified sports + /// + /// - Expected Behavior: + /// - Empty sports set → returns all trips unchanged + /// - Trip with matching sport → included + /// - Trip with none of the sports → excluded + /// - Trip with multiple sports, one matches → included static func filterBySport(_ trips: [Trip], sports: Set) -> [Trip] { guard !sports.isEmpty else { return trips } return trips.filter { trip in @@ -67,7 +116,21 @@ enum RouteFilters { } } - /// Filter trips by date range. Returns trips that overlap with the specified range. + /// Filters trips by date range overlap. + /// + /// - Parameters: + /// - trips: Trips to filter + /// - start: Range start date + /// - end: Range end date + /// - Returns: Trips that overlap with the specified range + /// + /// - Expected Behavior: + /// - Trip fully inside range → included + /// - Trip partially overlapping → included + /// - Trip fully outside range → excluded + /// - Trip ending on range start → included (same day counts as overlap) + /// - Trip starting on range end → included (same day counts as overlap) + /// - Uses calendar start-of-day for comparison static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] { let calendar = Calendar.current let rangeStart = calendar.startOfDay(for: start) @@ -82,12 +145,34 @@ enum RouteFilters { } } - /// Filter trips by status. Returns trips matching the specified status. + /// Filters trips by status. + /// + /// - Parameters: + /// - trips: Trips to filter + /// - status: Status to match + /// - Returns: Trips with matching status + /// + /// - Expected Behavior: + /// - Returns only trips where trip.status == status + /// - Empty trips → returns empty array static func filterByStatus(_ trips: [Trip], status: TripStatus) -> [Trip] { trips.filter { $0.status == status } } - /// Apply multiple filters. Returns intersection of all filter criteria. + /// Applies multiple filters in sequence. + /// + /// - Parameters: + /// - trips: Trips to filter + /// - sports: Optional sports filter (nil = no filter) + /// - dateRange: Optional date range filter (nil = no filter) + /// - status: Optional status filter (nil = no filter) + /// - Returns: Trips matching ALL specified criteria (intersection) + /// + /// - Expected Behavior: + /// - nil criteria → skip that filter + /// - Empty sports set → skip sport filter + /// - Order of filters doesn't affect result + /// - Result is intersection (AND) of all criteria static func applyFilters( _ trips: [Trip], sports: Set? = nil, diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 8f81162..4225009 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -26,6 +26,22 @@ import CoreLocation /// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9) /// Output: Single itinerary visiting LA → SF → Sacramento in order /// +/// - Expected Behavior: +/// - No date range → returns .failure with .missingDateRange +/// - No games in date range → returns .failure with .noGamesInRange +/// - With selectedRegions → only includes games in those regions +/// - With mustStopLocation → filters to home games in that city +/// - Empty games after must-stop filter → .failure with .noGamesInRange +/// - No valid routes from GameDAGRouter → .failure with .noValidRoutes +/// - All routes fail ItineraryBuilder → .failure with .constraintsUnsatisfiable +/// - Success → returns sorted itineraries based on leisureLevel +/// +/// - Invariants: +/// - Returned games are always within the date range +/// - Returned games are always chronologically ordered within each stop +/// - buildStops groups consecutive games at the same stadium +/// - Visiting A→B→A creates 3 separate stops (not 2) +/// final class ScenarioAPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index c59d12c..097e957 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -32,6 +32,23 @@ import CoreLocation /// Scenario B: Selected games planning /// Input: selected_games, date_range (or trip_duration), optional must_stop /// Output: Itinerary options connecting all selected games with possible bonus games +/// +/// - Expected Behavior: +/// - No selected games → returns .failure with .noValidRoutes +/// - No valid date ranges → returns .failure with .missingDateRange +/// - GameFirst mode → uses sliding windows with gameFirstTripDuration +/// - Explicit dateRange (non-gameFirst) → uses that range directly +/// - All anchor games MUST appear in every valid route +/// - Uses arrivalBeforeGameStart validator for travel segments +/// - No routes satisfy constraints → .failure with .constraintsUnsatisfiable +/// - Success → returns sorted itineraries based on leisureLevel +/// +/// - Invariants: +/// - Every returned route contains ALL selected (anchor) games +/// - Anchor games cannot be dropped for geographic convenience +/// - Sliding window always spans from first to last selected game +/// - Date ranges always contain all selected game dates +/// final class ScenarioBPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol @@ -142,6 +159,14 @@ final class ScenarioBPlanner: ScenarioPlanner { // Deduplicate validRoutes = deduplicateRoutes(validRoutes) + // Filter routes to only include those containing ALL anchor games + // This is critical: regional search uses filtered anchors, so we must + // verify each route satisfies the original anchor constraint + validRoutes = validRoutes.filter { route in + let routeGameIds = Set(route.map { $0.id }) + return anchorGameIds.isSubset(of: routeGameIds) + } + // Build itineraries for each valid route for routeGames in validRoutes { let stops = buildStops(from: routeGames, stadiums: request.stadiums) diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index 22799ae..1d39401 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -38,6 +38,27 @@ import CoreLocation /// Scenario C: Directional route planning from start city to end city /// Input: start_location, end_location, day_span (or date_range) /// Output: Top 5 itinerary options with games along the directional route +/// +/// - Expected Behavior: +/// - No start location → returns .failure with .missingLocations +/// - No end location → returns .failure with .missingLocations +/// - Missing coordinates → returns .failure with .missingLocations +/// - No stadiums in start city → returns .failure with .noGamesInRange +/// - No stadiums in end city → returns .failure with .noGamesInRange +/// - No valid date ranges → returns .failure with .missingDateRange +/// - Directional filtering: only stadiums making forward progress included +/// - Monotonic progress validation: route cannot backtrack significantly +/// - Start and end locations added as non-game stops +/// - No valid routes → .failure with .noValidRoutes +/// - Success → returns sorted itineraries based on leisureLevel +/// +/// - Invariants: +/// - Start stop has no games and appears first +/// - End stop has no games and appears last +/// - All game stops are between start and end +/// - Forward progress tolerance is 15% +/// - Max detour distance is 1.5x direct distance +/// final class ScenarioCPlanner: ScenarioPlanner { // MARK: - Configuration diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift index 6a45bbb..e0505eb 100644 --- a/SportsTime/Planning/Engine/ScenarioDPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -29,6 +29,22 @@ import CoreLocation /// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12) /// Output: Route visiting Boston → Toronto → New York /// +/// - Expected Behavior: +/// - No followTeamId → returns .failure with .missingTeamSelection +/// - No date range → returns .failure with .missingDateRange +/// - No team games found → returns .failure with .noGamesInRange +/// - No games in date range/region → returns .failure with .noGamesInRange +/// - filterToTeam returns BOTH home and away games for the team +/// - With selectedRegions → only includes games in those regions +/// - No valid routes → .failure with .noValidRoutes +/// - All routes fail constraints → .failure with .constraintsUnsatisfiable +/// - Success → returns sorted itineraries based on leisureLevel +/// +/// - Invariants: +/// - All returned games have homeTeamId == teamId OR awayTeamId == teamId +/// - Games are chronologically ordered within each stop +/// - Duplicate routes are removed +/// final class ScenarioDPlanner: ScenarioPlanner { // MARK: - ScenarioPlanner Protocol diff --git a/SportsTime/Planning/Engine/ScenarioPlanner.swift b/SportsTime/Planning/Engine/ScenarioPlanner.swift index e37f377..8c2dda5 100644 --- a/SportsTime/Planning/Engine/ScenarioPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioPlanner.swift @@ -8,7 +8,12 @@ import Foundation /// Protocol that all scenario planners must implement. -/// Each scenario (A, B, C) has its own isolated implementation. +/// Each scenario (A, B, C, D) has its own isolated implementation. +/// +/// - Invariants: +/// - Always returns either success or explicit failure, never throws +/// - Success contains ranked itinerary options +/// - Failure contains reason and any constraint violations protocol ScenarioPlanner { /// Plan itineraries for this scenario. @@ -17,10 +22,24 @@ protocol ScenarioPlanner { func plan(request: PlanningRequest) -> ItineraryResult } -/// Factory for creating the appropriate scenario planner +/// Factory for creating the appropriate scenario planner. +/// +/// - Expected Behavior: +/// - followTeamId != nil → ScenarioDPlanner +/// - selectedGames not empty → ScenarioBPlanner +/// - startLocation AND endLocation != nil → ScenarioCPlanner +/// - Otherwise → ScenarioAPlanner (default) +/// +/// Priority order: D > B > C > A (first matching wins) enum ScenarioPlannerFactory { - /// Creates the appropriate planner based on the request inputs + /// Creates the appropriate planner based on the request inputs. + /// + /// - Expected Behavior: + /// - followTeamId set → ScenarioDPlanner + /// - selectedGames not empty → ScenarioBPlanner + /// - Both start and end locations → ScenarioCPlanner + /// - Otherwise → ScenarioAPlanner static func planner(for request: PlanningRequest) -> ScenarioPlanner { print("🔍 ScenarioPlannerFactory: Selecting planner...") print(" - followTeamId: \(request.preferences.followTeamId ?? "nil")") @@ -51,7 +70,13 @@ enum ScenarioPlannerFactory { return ScenarioAPlanner() } - /// Classifies which scenario applies to this request + /// Classifies which scenario applies to this request. + /// + /// - Expected Behavior: + /// - followTeamId set → .scenarioD + /// - selectedGames not empty → .scenarioB + /// - Both start and end locations → .scenarioC + /// - Otherwise → .scenarioA static func classify(_ request: PlanningRequest) -> PlanningScenario { if request.preferences.followTeamId != nil { return .scenarioD diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 41d3813..72d5b90 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -9,6 +9,21 @@ import Foundation import CoreLocation +/// Travel estimation utilities for calculating distances and driving times. +/// +/// Uses Haversine formula for coordinate-based distance with a road routing factor, +/// or fallback distances when coordinates are unavailable. +/// +/// - Constants: +/// - averageSpeedMph: 60 mph +/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance) +/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable) +/// +/// - Invariants: +/// - All distance calculations are symmetric: distance(A,B) == distance(B,A) +/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0) +/// - Travel duration is always distance / averageSpeedMph +/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable) enum TravelEstimator { // MARK: - Constants @@ -19,8 +34,21 @@ enum TravelEstimator { // MARK: - Travel Estimation - /// Estimates a travel segment between two stops. - /// Returns nil if trip exceeds maximum allowed driving hours (2 days worth). + /// Estimates a travel segment between two ItineraryStops. + /// + /// - Parameters: + /// - from: Origin stop + /// - to: Destination stop + /// - constraints: Driving constraints (drivers, hours per day) + /// - Returns: TravelSegment or nil if unreachable + /// + /// - Expected Behavior: + /// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor + /// - Missing coordinates → uses fallback distance (300 miles) + /// - Same city (no coords) → 0 distance, 0 duration + /// - Driving hours > 5x maxDailyDrivingHours → returns nil + /// - Duration = distance / 60 mph + /// - Result distance in meters, duration in seconds static func estimate( from: ItineraryStop, to: ItineraryStop, @@ -47,7 +75,19 @@ enum TravelEstimator { } /// Estimates a travel segment between two LocationInputs. - /// Returns nil if coordinates are missing or if trip exceeds maximum allowed driving hours (2 days worth). + /// + /// - Parameters: + /// - from: Origin location + /// - to: Destination location + /// - constraints: Driving constraints + /// - Returns: TravelSegment or nil if unreachable/invalid + /// + /// - Expected Behavior: + /// - Missing from.coordinate → returns nil + /// - Missing to.coordinate → returns nil + /// - Valid coordinates → calculates distance using Haversine * roadRoutingFactor + /// - Driving hours > 5x maxDailyDrivingHours → returns nil + /// - Duration = distance / 60 mph static func estimate( from: LocationInput, to: LocationInput, @@ -81,8 +121,18 @@ enum TravelEstimator { // MARK: - Distance Calculations - /// Calculates distance in miles between two stops. - /// Uses Haversine formula if coordinates available, fallback otherwise. + /// Calculates road distance in miles between two ItineraryStops. + /// + /// - Parameters: + /// - from: Origin stop + /// - to: Destination stop + /// - Returns: Distance in miles + /// + /// - Expected Behavior: + /// - Both have coordinates → Haversine distance * 1.3 + /// - Either missing coordinates → fallback distance + /// - Same city (no coords) → 0 miles + /// - Different cities (no coords) → 300 miles static func calculateDistanceMiles( from: ItineraryStop, to: ItineraryStop @@ -94,7 +144,18 @@ enum TravelEstimator { return estimateFallbackDistance(from: from, to: to) } - /// Calculates distance in miles between two coordinates using Haversine. + /// Calculates straight-line distance in miles using Haversine formula. + /// + /// - Parameters: + /// - from: Origin coordinate + /// - to: Destination coordinate + /// - Returns: Straight-line distance in miles + /// + /// - Expected Behavior: + /// - Same point → 0 miles + /// - NYC to Boston → ~190 miles (validates formula accuracy) + /// - Symmetric: distance(A,B) == distance(B,A) + /// - Uses Earth radius of 3958.8 miles static func haversineDistanceMiles( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D @@ -114,7 +175,18 @@ enum TravelEstimator { return earthRadiusMiles * c } - /// Calculates distance in meters between two coordinates using Haversine. + /// Calculates straight-line distance in meters using Haversine formula. + /// + /// - Parameters: + /// - from: Origin coordinate + /// - to: Destination coordinate + /// - Returns: Straight-line distance in meters + /// + /// - Expected Behavior: + /// - Same point → 0 meters + /// - Symmetric: distance(A,B) == distance(B,A) + /// - Uses Earth radius of 6,371,000 meters + /// - haversineDistanceMeters / 1609.34 ≈ haversineDistanceMiles static func haversineDistanceMeters( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D @@ -135,6 +207,15 @@ enum TravelEstimator { } /// Fallback distance when coordinates aren't available. + /// + /// - Parameters: + /// - from: Origin stop + /// - to: Destination stop + /// - Returns: Estimated distance in miles + /// + /// - Expected Behavior: + /// - Same city → 0 miles + /// - Different cities → 300 miles (fallback constant) static func estimateFallbackDistance( from: ItineraryStop, to: ItineraryStop @@ -147,7 +228,20 @@ enum TravelEstimator { // MARK: - Travel Days - /// Calculates which calendar days travel spans. + /// Calculates which calendar days a driving segment spans. + /// + /// - Parameters: + /// - departure: Departure date/time + /// - drivingHours: Total driving hours + /// - Returns: Array of calendar days (start of day) that travel spans + /// + /// - Expected Behavior: + /// - 0 hours → [departure day] + /// - 1-8 hours → [departure day] (1 day) + /// - 8.01-16 hours → [departure day, next day] (2 days) + /// - 16.01-24 hours → [departure day, +1, +2] (3 days) + /// - All dates are normalized to start of day (midnight) + /// - Assumes 8 driving hours per day max static func calculateTravelDays( departure: Date, drivingHours: Double diff --git a/SportsTime/Planning/Engine/TripPlanningEngine.swift b/SportsTime/Planning/Engine/TripPlanningEngine.swift index 34384de..fe0b511 100644 --- a/SportsTime/Planning/Engine/TripPlanningEngine.swift +++ b/SportsTime/Planning/Engine/TripPlanningEngine.swift @@ -9,6 +9,19 @@ import Foundation /// Main entry point for trip planning. /// Delegates to scenario-specific planners via the ScenarioPlanner protocol. +/// +/// - Expected Behavior: +/// - Uses ScenarioPlannerFactory.planner(for:) to select the right planner +/// - Delegates entirely to the selected scenario planner +/// - Applies repeat city filter to successful results +/// - If all options violate repeat city constraint → .failure with .repeatCityViolation +/// - Passes through failures from scenario planners unchanged +/// +/// - Invariants: +/// - Never modifies the logic of scenario planners +/// - Always returns a result (success or failure), never throws +/// - Repeat city filter only applied when allowRepeatCities is false +/// final class TripPlanningEngine { /// Plans itineraries based on the request inputs. diff --git a/SportsTime/Planning/Models/PlanningModels.swift b/SportsTime/Planning/Models/PlanningModels.swift index 6ef737e..9f495bb 100644 --- a/SportsTime/Planning/Models/PlanningModels.swift +++ b/SportsTime/Planning/Models/PlanningModels.swift @@ -10,7 +10,13 @@ import CoreLocation // MARK: - Planning Scenario -/// Exactly one scenario per request. No blending. +/// Planning scenario types - exactly one per request. +/// +/// - Expected Behavior: +/// - scenarioA: User provides date range only, system finds games +/// - scenarioB: User selects specific games + date range +/// - scenarioC: User provides start/end locations, system plans route +/// - scenarioD: User follows a team's schedule enum PlanningScenario: Equatable { case scenarioA // Date range only case scenarioB // Selected games + date range @@ -195,10 +201,19 @@ struct ItineraryOption: Identifiable { /// - leisureLevel: The user's leisure preference /// - Returns: Sorted and ranked options (all options, no limit) /// - /// Sorting behavior: - /// - Packed: Most games first, then least driving - /// - Moderate: Best efficiency (games per driving hour) - /// - Relaxed: Least driving first, then fewer games + /// - Expected Behavior: + /// - Empty options → empty result + /// - All options are returned (no filtering) + /// - Ranks are reassigned 1, 2, 3... after sorting + /// + /// Sorting behavior by leisure level: + /// - Packed: Most games first, then least driving (maximize games) + /// - Moderate: Best efficiency (games/hour), then most games (balance) + /// - Relaxed: Least driving first, then fewer games (minimize driving) + /// + /// - Invariants: + /// - Output count == input count + /// - Ranks are sequential starting at 1 static func sortByLeisure( _ options: [ItineraryOption], leisureLevel: LeisureLevel @@ -270,7 +285,19 @@ struct ItineraryStop: Identifiable, Hashable { // MARK: - Driving Constraints -/// Driving feasibility constraints. +/// Driving feasibility constraints based on number of drivers. +/// +/// - Expected Behavior: +/// - numberOfDrivers < 1 → clamped to 1 +/// - maxHoursPerDriverPerDay < 1.0 → clamped to 1.0 +/// - maxDailyDrivingHours = numberOfDrivers * maxHoursPerDriverPerDay +/// - Default: 1 driver, 8 hours/day = 8 total hours +/// - 2 drivers, 8 hours each = 16 total hours +/// +/// - Invariants: +/// - numberOfDrivers >= 1 +/// - maxHoursPerDriverPerDay >= 1.0 +/// - maxDailyDrivingHours >= 1.0 struct DrivingConstraints { let numberOfDrivers: Int let maxHoursPerDriverPerDay: Double diff --git a/SportsTimeTests/Data/CanonicalSportTests.swift b/SportsTimeTests/Data/CanonicalSportTests.swift deleted file mode 100644 index 1a743a3..0000000 --- a/SportsTimeTests/Data/CanonicalSportTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// CanonicalSportTests.swift -// SportsTimeTests -// -// Tests for CanonicalSport SwiftData model and its conversion to DynamicSport domain model. -// - -import XCTest -@testable import SportsTime - -/// Tests for CanonicalSport model -/// Note: These tests verify the model's initialization and toDomain() conversion without -/// requiring a full SwiftData container, since the @Model macro generates the persistence layer. -final class CanonicalSportTests: XCTestCase { - - func test_CanonicalSport_ConvertsToDynamicSportDomainModel() { - // Given: A CanonicalSport instance - let canonical = CanonicalSport( - id: "xfl", - abbreviation: "XFL", - displayName: "XFL Football", - iconName: "football.fill", - colorHex: "#E31837", - seasonStartMonth: 2, - seasonEndMonth: 5, - isActive: true - ) - - // When: Converting to domain model - let domain = canonical.toDomain() - - // Then: All properties are correctly mapped - XCTAssertEqual(domain.id, "xfl") - XCTAssertEqual(domain.abbreviation, "XFL") - XCTAssertEqual(domain.displayName, "XFL Football") - XCTAssertEqual(domain.iconName, "football.fill") - XCTAssertEqual(domain.colorHex, "#E31837") - XCTAssertEqual(domain.seasonStartMonth, 2) - XCTAssertEqual(domain.seasonEndMonth, 5) - } - - func test_CanonicalSport_InitializesWithDefaultValues() { - // Given/When: Creating a CanonicalSport with only required parameters - let sport = CanonicalSport( - id: "test", - abbreviation: "TST", - displayName: "Test Sport", - iconName: "star.fill", - colorHex: "#000000", - seasonStartMonth: 1, - seasonEndMonth: 12 - ) - - // Then: Default values are set correctly - XCTAssertTrue(sport.isActive) - XCTAssertEqual(sport.schemaVersion, SchemaVersion.current) - XCTAssertEqual(sport.source, .cloudKit) - } - - func test_CanonicalSport_SourcePropertyWorksCorrectly() { - // Given: A CanonicalSport - let sport = CanonicalSport( - id: "test", - abbreviation: "TST", - displayName: "Test Sport", - iconName: "star.fill", - colorHex: "#000000", - seasonStartMonth: 1, - seasonEndMonth: 12, - source: .bundled - ) - - // Then: Source is correctly stored and retrieved - XCTAssertEqual(sport.source, .bundled) - - // When: Changing the source - sport.source = .userCorrection - - // Then: Source is updated - XCTAssertEqual(sport.source, .userCorrection) - XCTAssertEqual(sport.sourceRaw, "userCorrection") - } - - func test_CanonicalSport_HasUniqueIdAttribute() { - // Given: Two CanonicalSport instances with the same id - let sport1 = CanonicalSport( - id: "xfl", - abbreviation: "XFL", - displayName: "XFL Football", - iconName: "football.fill", - colorHex: "#E31837", - seasonStartMonth: 2, - seasonEndMonth: 5 - ) - - let sport2 = CanonicalSport( - id: "xfl", - abbreviation: "XFL", - displayName: "XFL Football Updated", - iconName: "football.fill", - colorHex: "#E31837", - seasonStartMonth: 2, - seasonEndMonth: 5 - ) - - // Then: Both instances have the same id (SwiftData's @Attribute(.unique) handles uniqueness at persistence level) - XCTAssertEqual(sport1.id, sport2.id) - } -} diff --git a/SportsTimeTests/Domain/AchievementDefinitionsTests.swift b/SportsTimeTests/Domain/AchievementDefinitionsTests.swift new file mode 100644 index 0000000..7302a6d --- /dev/null +++ b/SportsTimeTests/Domain/AchievementDefinitionsTests.swift @@ -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[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) + } +} diff --git a/SportsTimeTests/Domain/AnySportTests.swift b/SportsTimeTests/Domain/AnySportTests.swift new file mode 100644 index 0000000..52bd79c --- /dev/null +++ b/SportsTimeTests/Domain/AnySportTests.swift @@ -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) + } + } +} diff --git a/SportsTimeTests/Domain/DivisionTests.swift b/SportsTimeTests/Domain/DivisionTests.swift new file mode 100644 index 0000000..d5eede5 --- /dev/null +++ b/SportsTimeTests/Domain/DivisionTests.swift @@ -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.. 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 = [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) } } diff --git a/SportsTimeTests/Domain/GameTests.swift b/SportsTimeTests/Domain/GameTests.swift new file mode 100644 index 0000000..1e545f5 --- /dev/null +++ b/SportsTimeTests/Domain/GameTests.swift @@ -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) + } +} diff --git a/SportsTimeTests/Domain/PollTests.swift b/SportsTimeTests/Domain/PollTests.swift deleted file mode 100644 index 2b0fd4a..0000000 --- a/SportsTimeTests/Domain/PollTests.swift +++ /dev/null @@ -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() - 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.. 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%") + } +} diff --git a/SportsTimeTests/Domain/RegionTests.swift b/SportsTimeTests/Domain/RegionTests.swift new file mode 100644 index 0000000..5bff2f8 --- /dev/null +++ b/SportsTimeTests/Domain/RegionTests.swift @@ -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") + } +} diff --git a/SportsTimeTests/Domain/SportTests.swift b/SportsTimeTests/Domain/SportTests.swift index 2e8cfeb..0827177 100644 --- a/SportsTimeTests/Domain/SportTests.swift +++ b/SportsTimeTests/Domain/SportTests.swift @@ -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 = [] + 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) + } } } diff --git a/SportsTimeTests/Domain/StadiumTests.swift b/SportsTimeTests/Domain/StadiumTests.swift new file mode 100644 index 0000000..ac8e2e5 --- /dev/null +++ b/SportsTimeTests/Domain/StadiumTests.swift @@ -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") + } +} diff --git a/SportsTimeTests/Domain/TeamTests.swift b/SportsTimeTests/Domain/TeamTests.swift new file mode 100644 index 0000000..be10276 --- /dev/null +++ b/SportsTimeTests/Domain/TeamTests.swift @@ -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") + } +} diff --git a/SportsTimeTests/Domain/TravelSegmentTests.swift b/SportsTimeTests/Domain/TravelSegmentTests.swift new file mode 100644 index 0000000..79acbbf --- /dev/null +++ b/SportsTimeTests/Domain/TravelSegmentTests.swift @@ -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) + } +} diff --git a/SportsTimeTests/Domain/TripPollTests.swift b/SportsTimeTests/Domain/TripPollTests.swift new file mode 100644 index 0000000..9357f04 --- /dev/null +++ b/SportsTimeTests/Domain/TripPollTests.swift @@ -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.. 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..= 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) + } + } +} diff --git a/SportsTimeTests/Domain/TripStopTests.swift b/SportsTimeTests/Domain/TripStopTests.swift new file mode 100644 index 0000000..c335445 --- /dev/null +++ b/SportsTimeTests/Domain/TripStopTests.swift @@ -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) + } +} diff --git a/SportsTimeTests/Domain/TripTests.swift b/SportsTimeTests/Domain/TripTests.swift new file mode 100644 index 0000000..74622ff --- /dev/null +++ b/SportsTimeTests/Domain/TripTests.swift @@ -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.. POISearchService.POI { + POISearchService.POI( + id: UUID(), + name: "Test POI", + category: .restaurant, + coordinate: CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0), + distanceMeters: distanceMeters, + address: nil + ) + } + + // MARK: - Specification Tests: formattedDistance + + /// - Expected Behavior: Distances < 0.1 miles format as feet + @Test("formattedDistance: short distances show feet") + func formattedDistance_feet() { + // 100 meters = ~328 feet = ~0.062 miles (less than 0.1) + let poi = makePOI(distanceMeters: 100) + let formatted = poi.formattedDistance + + #expect(formatted.contains("ft")) + #expect(!formatted.contains("mi")) + } + + /// - Expected Behavior: Distances >= 0.1 miles format as miles + @Test("formattedDistance: longer distances show miles") + func formattedDistance_miles() { + // 500 meters = ~0.31 miles (greater than 0.1) + let poi = makePOI(distanceMeters: 500) + let formatted = poi.formattedDistance + + #expect(formatted.contains("mi")) + #expect(!formatted.contains("ft")) + } + + /// - Expected Behavior: Boundary at 0.1 miles (~161 meters) + @Test("formattedDistance: boundary at 0.1 miles") + func formattedDistance_boundary() { + // 0.1 miles = ~161 meters + let justUnderPOI = makePOI(distanceMeters: 160) // Just under 0.1 miles + let justOverPOI = makePOI(distanceMeters: 162) // Just over 0.1 miles + + #expect(justUnderPOI.formattedDistance.contains("ft")) + #expect(justOverPOI.formattedDistance.contains("mi")) + } + + /// - Expected Behavior: Zero distance formats correctly + @Test("formattedDistance: handles zero distance") + func formattedDistance_zero() { + let poi = makePOI(distanceMeters: 0) + let formatted = poi.formattedDistance + #expect(formatted.contains("0") || formatted.contains("ft")) + } + + /// - Expected Behavior: Large distance formats correctly + @Test("formattedDistance: handles large distance") + func formattedDistance_large() { + // 5000 meters = ~3.1 miles + let poi = makePOI(distanceMeters: 5000) + let formatted = poi.formattedDistance + + #expect(formatted.contains("mi")) + #expect(formatted.contains("3.1") || formatted.contains("3.") || Double(formatted.replacingOccurrences(of: " mi", with: ""))! > 3.0) + } + + // MARK: - Invariant Tests + + /// - Invariant: formattedDistance always contains a unit + @Test("Invariant: formattedDistance always has unit") + func invariant_formattedDistanceHasUnit() { + let testDistances: [Double] = [0, 50, 100, 160, 162, 500, 1000, 5000] + + for distance in testDistances { + let poi = makePOI(distanceMeters: distance) + let formatted = poi.formattedDistance + #expect(formatted.contains("ft") || formatted.contains("mi")) + } + } +} + +// MARK: - POICategory Tests + +@Suite("POICategory") +struct POICategoryTests { + + // MARK: - Specification Tests: displayName + + /// - Expected Behavior: Each category has a human-readable display name + @Test("displayName: returns readable name") + func displayName_readable() { + #expect(POISearchService.POICategory.restaurant.displayName == "Restaurant") + #expect(POISearchService.POICategory.attraction.displayName == "Attraction") + #expect(POISearchService.POICategory.entertainment.displayName == "Entertainment") + #expect(POISearchService.POICategory.nightlife.displayName == "Nightlife") + #expect(POISearchService.POICategory.museum.displayName == "Museum") + } + + // MARK: - Specification Tests: iconName + + /// - Expected Behavior: Each category has a valid SF Symbol name + @Test("iconName: returns SF Symbol name") + func iconName_sfSymbol() { + #expect(POISearchService.POICategory.restaurant.iconName == "fork.knife") + #expect(POISearchService.POICategory.attraction.iconName == "star.fill") + #expect(POISearchService.POICategory.entertainment.iconName == "theatermasks.fill") + #expect(POISearchService.POICategory.nightlife.iconName == "moon.stars.fill") + #expect(POISearchService.POICategory.museum.iconName == "building.columns.fill") + } + + // MARK: - Specification Tests: searchQuery + + /// - Expected Behavior: Each category has a search-friendly query string + @Test("searchQuery: returns search string") + func searchQuery_searchString() { + #expect(POISearchService.POICategory.restaurant.searchQuery == "restaurants") + #expect(POISearchService.POICategory.attraction.searchQuery == "tourist attractions") + #expect(POISearchService.POICategory.entertainment.searchQuery == "entertainment") + #expect(POISearchService.POICategory.nightlife.searchQuery == "bars nightlife") + #expect(POISearchService.POICategory.museum.searchQuery == "museums") + } + + // MARK: - Invariant Tests + + /// - Invariant: All categories have non-empty properties + @Test("Invariant: all categories have non-empty properties") + func invariant_nonEmptyProperties() { + for category in POISearchService.POICategory.allCases { + #expect(!category.displayName.isEmpty) + #expect(!category.iconName.isEmpty) + #expect(!category.searchQuery.isEmpty) + } + } + + /// - Invariant: CaseIterable includes all cases + @Test("Invariant: CaseIterable includes all cases") + func invariant_allCasesIncluded() { + #expect(POISearchService.POICategory.allCases.count == 5) + #expect(POISearchService.POICategory.allCases.contains(.restaurant)) + #expect(POISearchService.POICategory.allCases.contains(.attraction)) + #expect(POISearchService.POICategory.allCases.contains(.entertainment)) + #expect(POISearchService.POICategory.allCases.contains(.nightlife)) + #expect(POISearchService.POICategory.allCases.contains(.museum)) + } +} + +// MARK: - POISearchError Tests + +@Suite("POISearchError") +struct POISearchErrorTests { + + // MARK: - Specification Tests: errorDescription + + /// - Expected Behavior: searchFailed includes the reason + @Test("errorDescription: searchFailed includes reason") + func errorDescription_searchFailed() { + let error = POISearchService.POISearchError.searchFailed("Network error") + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.contains("Network error") || error.errorDescription!.lowercased().contains("search")) + } + + /// - Expected Behavior: noResults explains no POIs found + @Test("errorDescription: noResults mentions no results") + func errorDescription_noResults() { + let error = POISearchService.POISearchError.noResults + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.lowercased().contains("no") || error.errorDescription!.lowercased().contains("found")) + } + + // MARK: - Invariant Tests + + /// - Invariant: All errors have non-empty descriptions + @Test("Invariant: all errors have descriptions") + func invariant_allHaveDescriptions() { + let errors: [POISearchService.POISearchError] = [ + .searchFailed("test"), + .noResults + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(!error.errorDescription!.isEmpty) + } + } +} diff --git a/SportsTimeTests/Export/ShareableContentTests.swift b/SportsTimeTests/Export/ShareableContentTests.swift new file mode 100644 index 0000000..8beb9db --- /dev/null +++ b/SportsTimeTests/Export/ShareableContentTests.swift @@ -0,0 +1,274 @@ +// +// ShareableContentTests.swift +// SportsTimeTests +// +// TDD specification tests for ShareableContent types. +// + +import Testing +import Foundation +import SwiftUI +@testable import SportsTime + +// MARK: - ShareCardType Tests + +@Suite("ShareCardType") +struct ShareCardTypeTests { + + // MARK: - Specification Tests: CaseIterable + + /// - Expected Behavior: Includes all expected card types + @Test("allCases: includes all card types") + func allCases_includesAll() { + let allTypes = ShareCardType.allCases + + #expect(allTypes.contains(.tripSummary)) + #expect(allTypes.contains(.achievementSpotlight)) + #expect(allTypes.contains(.achievementCollection)) + #expect(allTypes.contains(.achievementMilestone)) + #expect(allTypes.contains(.achievementContext)) + #expect(allTypes.contains(.stadiumProgress)) + } + + // MARK: - Invariant Tests + + /// - Invariant: Each type has a unique rawValue + @Test("Invariant: unique rawValues") + func invariant_uniqueRawValues() { + let allTypes = ShareCardType.allCases + let rawValues = allTypes.map { $0.rawValue } + let uniqueRawValues = Set(rawValues) + + #expect(rawValues.count == uniqueRawValues.count) + } + + /// - Invariant: Count matches expected number + @Test("Invariant: correct count") + func invariant_correctCount() { + #expect(ShareCardType.allCases.count == 6) + } +} + +// MARK: - ShareTheme Tests + +@Suite("ShareTheme") +struct ShareThemeTests { + + // MARK: - Specification Tests: Static Themes + + /// - Expected Behavior: All preset themes are accessible + @Test("Static themes: all presets exist") + func staticThemes_allExist() { + // Access each theme to ensure they exist + let _ = ShareTheme.dark + let _ = ShareTheme.light + let _ = ShareTheme.midnight + let _ = ShareTheme.forest + let _ = ShareTheme.sunset + let _ = ShareTheme.berry + let _ = ShareTheme.ocean + let _ = ShareTheme.slate + + #expect(true) // If we got here, all themes exist + } + + /// - Expected Behavior: all array contains all themes + @Test("all: contains all preset themes") + func all_containsAllThemes() { + let all = ShareTheme.all + + #expect(all.count == 8) + #expect(all.contains(where: { $0.id == "dark" })) + #expect(all.contains(where: { $0.id == "light" })) + #expect(all.contains(where: { $0.id == "midnight" })) + #expect(all.contains(where: { $0.id == "forest" })) + #expect(all.contains(where: { $0.id == "sunset" })) + #expect(all.contains(where: { $0.id == "berry" })) + #expect(all.contains(where: { $0.id == "ocean" })) + #expect(all.contains(where: { $0.id == "slate" })) + } + + // MARK: - Specification Tests: theme(byId:) + + /// - Expected Behavior: Returns matching theme by id + @Test("theme(byId:): returns matching theme") + func themeById_returnsMatching() { + let dark = ShareTheme.theme(byId: "dark") + let light = ShareTheme.theme(byId: "light") + + #expect(dark.id == "dark") + #expect(light.id == "light") + } + + /// - Expected Behavior: Returns dark theme for unknown id + @Test("theme(byId:): returns dark for unknown id") + func themeById_unknownReturnsDark() { + let unknown = ShareTheme.theme(byId: "nonexistent") + #expect(unknown.id == "dark") + } + + /// - Expected Behavior: Finds all valid themes by id + @Test("theme(byId:): finds all valid themes") + func themeById_findsAllValid() { + let ids = ["dark", "light", "midnight", "forest", "sunset", "berry", "ocean", "slate"] + + for id in ids { + let theme = ShareTheme.theme(byId: id) + #expect(theme.id == id) + } + } + + // MARK: - Specification Tests: Properties + + /// - Expected Behavior: Each theme has required properties + @Test("Properties: themes have all required fields") + func properties_allRequired() { + for theme in ShareTheme.all { + #expect(!theme.id.isEmpty) + #expect(!theme.name.isEmpty) + #expect(theme.gradientColors.count >= 2) + // Colors exist (can't easily test Color values) + } + } + + // MARK: - Invariant Tests + + /// - Invariant: All themes have unique ids + @Test("Invariant: unique theme ids") + func invariant_uniqueIds() { + let ids = ShareTheme.all.map { $0.id } + let uniqueIds = Set(ids) + + #expect(ids.count == uniqueIds.count) + } + + /// - Invariant: All themes are Hashable and Identifiable + @Test("Invariant: themes are Hashable") + func invariant_hashable() { + var themeSet: Set = [] + + for theme in ShareTheme.all { + themeSet.insert(theme) + } + + #expect(themeSet.count == ShareTheme.all.count) + } +} + +// MARK: - ShareError Tests + +@Suite("ShareError") +struct ShareErrorTests { + + // MARK: - Specification Tests: errorDescription + + /// - Expected Behavior: renderingFailed explains render failure + @Test("errorDescription: renderingFailed mentions render") + func errorDescription_renderingFailed() { + let error = ShareError.renderingFailed + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.lowercased().contains("render") || error.errorDescription!.lowercased().contains("failed")) + } + + /// - Expected Behavior: mapSnapshotFailed explains snapshot failure + @Test("errorDescription: mapSnapshotFailed mentions map") + func errorDescription_mapSnapshotFailed() { + let error = ShareError.mapSnapshotFailed + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.lowercased().contains("map") || error.errorDescription!.lowercased().contains("snapshot")) + } + + /// - Expected Behavior: instagramNotInstalled explains Instagram requirement + @Test("errorDescription: instagramNotInstalled mentions Instagram") + func errorDescription_instagramNotInstalled() { + let error = ShareError.instagramNotInstalled + #expect(error.errorDescription != nil) + #expect(error.errorDescription!.lowercased().contains("instagram")) + } + + // MARK: - Invariant Tests + + /// - Invariant: All errors have non-empty descriptions + @Test("Invariant: all errors have descriptions") + func invariant_allHaveDescriptions() { + let errors: [ShareError] = [ + .renderingFailed, + .mapSnapshotFailed, + .instagramNotInstalled + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(!error.errorDescription!.isEmpty) + } + } +} + +// MARK: - ShareCardDimensions Tests + +@Suite("ShareCardDimensions") +struct ShareCardDimensionsTests { + + // MARK: - Specification Tests: Static Constants + + /// - Expected Behavior: Card size is standard Instagram story size + @Test("cardSize: is 1080x1920") + func cardSize_instagramStory() { + #expect(ShareCardDimensions.cardSize.width == 1080) + #expect(ShareCardDimensions.cardSize.height == 1920) + } + + /// - Expected Behavior: Map snapshot size fits within card with padding + @Test("mapSnapshotSize: has reasonable dimensions") + func mapSnapshotSize_reasonable() { + #expect(ShareCardDimensions.mapSnapshotSize.width == 960) + #expect(ShareCardDimensions.mapSnapshotSize.height == 480) + } + + /// - Expected Behavior: Route map size fits within card with padding + @Test("routeMapSize: has reasonable dimensions") + func routeMapSize_reasonable() { + #expect(ShareCardDimensions.routeMapSize.width == 960) + #expect(ShareCardDimensions.routeMapSize.height == 576) + } + + /// - Expected Behavior: Padding value is positive + @Test("padding: is positive") + func padding_positive() { + #expect(ShareCardDimensions.padding == 60) + #expect(ShareCardDimensions.padding > 0) + } + + /// - Expected Behavior: Header height is positive + @Test("headerHeight: is positive") + func headerHeight_positive() { + #expect(ShareCardDimensions.headerHeight == 120) + #expect(ShareCardDimensions.headerHeight > 0) + } + + /// - Expected Behavior: Footer height is positive + @Test("footerHeight: is positive") + func footerHeight_positive() { + #expect(ShareCardDimensions.footerHeight == 100) + #expect(ShareCardDimensions.footerHeight > 0) + } + + // MARK: - Invariant Tests + + /// - Invariant: Card aspect ratio is 9:16 (portrait) + @Test("Invariant: card is portrait aspect ratio") + func invariant_portraitAspectRatio() { + let aspectRatio = ShareCardDimensions.cardSize.width / ShareCardDimensions.cardSize.height + // 9:16 = 0.5625 + #expect(abs(aspectRatio - 0.5625) < 0.001) + } + + /// - Invariant: Map sizes fit within card with padding + @Test("Invariant: maps fit within card") + func invariant_mapsFitWithinCard() { + let availableWidth = ShareCardDimensions.cardSize.width - (ShareCardDimensions.padding * 2) + + #expect(ShareCardDimensions.mapSnapshotSize.width <= availableWidth) + #expect(ShareCardDimensions.routeMapSize.width <= availableWidth) + } +} diff --git a/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift b/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift deleted file mode 100644 index 2a5b92a..0000000 --- a/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import XCTest -import SwiftData -@testable import SportsTime - -@MainActor -final class GamesHistoryViewModelTests: XCTestCase { - var modelContainer: ModelContainer! - var modelContext: ModelContext! - - override func setUp() async throws { - let config = ModelConfiguration(isStoredInMemoryOnly: true) - modelContainer = try ModelContainer( - for: StadiumVisit.self, Achievement.self, UserPreferences.self, - configurations: config - ) - modelContext = modelContainer.mainContext - } - - override func tearDown() async throws { - modelContainer = nil - modelContext = nil - } - - func test_GamesHistoryViewModel_GroupsVisitsByYear() async throws { - // Given: Visits in different years - let visit2026 = StadiumVisit( - stadiumId: "stadium-1", - stadiumNameAtVisit: "Stadium 2026", - visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!, - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - let visit2025 = StadiumVisit( - stadiumId: "stadium-2", - stadiumNameAtVisit: "Stadium 2025", - visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!, - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - modelContext.insert(visit2026) - modelContext.insert(visit2025) - try modelContext.save() - - // When: Loading games history - let viewModel = GamesHistoryViewModel(modelContext: modelContext) - await viewModel.loadGames() - - // Then: Visits are grouped by year - XCTAssertEqual(viewModel.visitsByYear.keys.count, 2, "Should have 2 years") - XCTAssertTrue(viewModel.visitsByYear.keys.contains(2026)) - XCTAssertTrue(viewModel.visitsByYear.keys.contains(2025)) - } - - func test_GamesHistoryViewModel_FiltersBySport() async throws { - // Given: Visits to different sport stadiums - // Note: This requires stadiums in AppDataProvider to map stadiumId → sport - let mlbVisit = StadiumVisit( - stadiumId: "yankee-stadium", - stadiumNameAtVisit: "Yankee Stadium", - visitDate: Date(), - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - modelContext.insert(mlbVisit) - try modelContext.save() - - // When: Filtering by MLB - let viewModel = GamesHistoryViewModel(modelContext: modelContext) - await viewModel.loadGames() - viewModel.selectedSports = [.mlb] - - // Then: Only MLB visits shown - let filteredCount = viewModel.filteredVisits.count - XCTAssertGreaterThanOrEqual(filteredCount, 0, "Filter should work without crashing") - } - - func test_GamesHistoryViewModel_SortsMostRecentFirst() async throws { - // Given: Visits on different dates - let oldVisit = StadiumVisit( - stadiumId: "stadium-1", - stadiumNameAtVisit: "Old Stadium", - visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - let newVisit = StadiumVisit( - stadiumId: "stadium-2", - stadiumNameAtVisit: "New Stadium", - visitDate: Date(), - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - modelContext.insert(oldVisit) - modelContext.insert(newVisit) - try modelContext.save() - - // When: Loading games - let viewModel = GamesHistoryViewModel(modelContext: modelContext) - await viewModel.loadGames() - - // Then: Most recent first within year - let visits = viewModel.allVisits - XCTAssertEqual(visits.first?.stadiumNameAtVisit, "New Stadium", "Most recent should be first") - } -} diff --git a/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift b/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift deleted file mode 100644 index 8f38555..0000000 --- a/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import XCTest -import MapKit -@testable import SportsTime - -final class ProgressMapViewTests: XCTestCase { - - @MainActor - func test_MapViewModel_TracksUserInteraction() { - // Given: A map view model - let viewModel = MapInteractionViewModel() - - // When: User interacts with map (zoom/pan) - viewModel.userDidInteract() - - // Then: Interaction is tracked - XCTAssertTrue(viewModel.hasUserInteracted, "Should track user interaction") - XCTAssertTrue(viewModel.shouldShowResetButton, "Should show reset button after interaction") - } - - @MainActor - func test_MapViewModel_ResetClearsInteraction() { - // Given: A map with user interaction - let viewModel = MapInteractionViewModel() - viewModel.userDidInteract() - - // When: User resets the view - viewModel.resetToDefault() - - // Then: Interaction flag is cleared - XCTAssertFalse(viewModel.hasUserInteracted, "Should clear interaction flag after reset") - XCTAssertFalse(viewModel.shouldShowResetButton, "Should hide reset button after reset") - } - - @MainActor - func test_MapViewModel_ZoomToStadium_SetsCorrectRegion() { - // Given: A map view model - let viewModel = MapInteractionViewModel() - - // When: Zooming to a stadium location - let stadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262) // Yankee Stadium - viewModel.zoomToStadium(at: stadiumCoord) - - // Then: Region is set to city-level zoom - XCTAssertEqual(viewModel.region.center.latitude, stadiumCoord.latitude, accuracy: 0.001) - XCTAssertEqual(viewModel.region.center.longitude, stadiumCoord.longitude, accuracy: 0.001) - XCTAssertEqual(viewModel.region.span.latitudeDelta, 0.01, accuracy: 0.005, "Should use city-level zoom span") - } -} diff --git a/SportsTimeTests/Features/Progress/VisitListTests.swift b/SportsTimeTests/Features/Progress/VisitListTests.swift deleted file mode 100644 index 5ad214b..0000000 --- a/SportsTimeTests/Features/Progress/VisitListTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -import XCTest -import SwiftData -@testable import SportsTime - -@MainActor -final class VisitListTests: XCTestCase { - var modelContainer: ModelContainer! - var modelContext: ModelContext! - - override func setUp() async throws { - let config = ModelConfiguration(isStoredInMemoryOnly: true) - modelContainer = try ModelContainer( - for: StadiumVisit.self, Achievement.self, UserPreferences.self, - configurations: config - ) - modelContext = modelContainer.mainContext - } - - override func tearDown() async throws { - modelContainer = nil - modelContext = nil - } - - func test_VisitsForStadium_ReturnsAllVisitsSortedByDate() async throws { - // Given: Multiple visits to the same stadium - let stadiumId = "yankee-stadium" - - let visit1 = StadiumVisit( - stadiumId: stadiumId, - stadiumNameAtVisit: "Yankee Stadium", - visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - let visit2 = StadiumVisit( - stadiumId: stadiumId, - stadiumNameAtVisit: "Yankee Stadium", - visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - - let visit3 = StadiumVisit( - stadiumId: stadiumId, - stadiumNameAtVisit: "Yankee Stadium", - visitDate: Date(), // today - sport: .mlb, - visitType: .tour, - dataSource: .fullyManual - ) - - modelContext.insert(visit1) - modelContext.insert(visit2) - modelContext.insert(visit3) - try modelContext.save() - - // When: Fetching visits for that stadium - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.stadiumId == stadiumId }, - sortBy: [SortDescriptor(\.visitDate, order: .reverse)] - ) - let visits = try modelContext.fetch(descriptor) - - // Then: All visits returned, most recent first - XCTAssertEqual(visits.count, 3, "Should return all 3 visits") - XCTAssertEqual(visits[0].visitType, .tour, "Most recent visit should be first") - XCTAssertEqual(visits[2].visitType, .game, "Oldest visit should be last") - } - - func test_VisitCountForStadium_ReturnsCorrectCount() async throws { - // Given: 3 visits to one stadium, 1 to another - let stadium1 = "yankee-stadium" - let stadium2 = "fenway-park" - - for i in 0..<3 { - let visit = StadiumVisit( - stadiumId: stadium1, - stadiumNameAtVisit: "Yankee Stadium", - visitDate: Date().addingTimeInterval(Double(-i * 86400)), - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - modelContext.insert(visit) - } - - let fenwayVisit = StadiumVisit( - stadiumId: stadium2, - stadiumNameAtVisit: "Fenway Park", - visitDate: Date(), - sport: .mlb, - visitType: .game, - dataSource: .fullyManual - ) - modelContext.insert(fenwayVisit) - try modelContext.save() - - // When: Counting visits per stadium - let yankeeDescriptor = FetchDescriptor( - predicate: #Predicate { $0.stadiumId == stadium1 } - ) - let fenwayDescriptor = FetchDescriptor( - predicate: #Predicate { $0.stadiumId == stadium2 } - ) - - let yankeeCount = try modelContext.fetchCount(yankeeDescriptor) - let fenwayCount = try modelContext.fetchCount(fenwayDescriptor) - - // Then: Correct counts - XCTAssertEqual(yankeeCount, 3) - XCTAssertEqual(fenwayCount, 1) - } -} diff --git a/SportsTimeTests/Fixtures/FixtureGenerator.swift b/SportsTimeTests/Fixtures/FixtureGenerator.swift deleted file mode 100644 index 305fcfc..0000000 --- a/SportsTimeTests/Fixtures/FixtureGenerator.swift +++ /dev/null @@ -1,490 +0,0 @@ -// -// FixtureGenerator.swift -// SportsTimeTests -// -// Generates synthetic test data for unit and integration tests. -// Uses deterministic seeding for reproducible test results. -// - -import Foundation -import CoreLocation -@testable import SportsTime - -// MARK: - Random Number Generator with Seed - -struct SeededRandomNumberGenerator: RandomNumberGenerator { - private var state: UInt64 - - init(seed: UInt64) { - self.state = seed - } - - mutating func next() -> UInt64 { - // xorshift64 algorithm for reproducibility - state ^= state << 13 - state ^= state >> 7 - state ^= state << 17 - return state - } -} - -// MARK: - Fixture Generator - -struct FixtureGenerator { - - // MARK: - Configuration - - struct Configuration { - var seed: UInt64 = 12345 - var gameCount: Int = 50 - var stadiumCount: Int = 30 - var teamCount: Int = 30 - var dateRange: ClosedRange = { - let start = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 1))! - let end = Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: 30))! - return start...end - }() - var sports: Set = [.mlb, .nba, .nhl] - var geographicSpread: GeographicSpread = .nationwide - - enum GeographicSpread { - case nationwide // Full US coverage - case regional // Concentrated in one region - case corridor // Along a route (e.g., East Coast) - case cluster // Single metro area - } - - static var `default`: Configuration { Configuration() } - static var minimal: Configuration { Configuration(gameCount: 5, stadiumCount: 5, teamCount: 5) } - static var small: Configuration { Configuration(gameCount: 50, stadiumCount: 15, teamCount: 15) } - static var medium: Configuration { Configuration(gameCount: 500, stadiumCount: 30, teamCount: 30) } - static var large: Configuration { Configuration(gameCount: 2000, stadiumCount: 30, teamCount: 60) } - static var stress: Configuration { Configuration(gameCount: 10000, stadiumCount: 30, teamCount: 60) } - } - - // MARK: - Generated Data Container - - struct GeneratedData { - let stadiums: [Stadium] - let teams: [Team] - let games: [Game] - let stadiumsById: [String: Stadium] - let teamsById: [String: Team] - - func richGame(from game: Game) -> RichGame? { - guard let homeTeam = teamsById[game.homeTeamId], - let awayTeam = teamsById[game.awayTeamId], - let stadium = stadiumsById[game.stadiumId] else { - return nil - } - return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) - } - - func richGames() -> [RichGame] { - games.compactMap { richGame(from: $0) } - } - } - - // MARK: - City Data for Realistic Generation - - private static let cityData: [(name: String, state: String, lat: Double, lon: Double, region: Region)] = [ - // East Coast - ("New York", "NY", 40.7128, -73.9352, .east), - ("Boston", "MA", 42.3601, -71.0589, .east), - ("Philadelphia", "PA", 39.9526, -75.1652, .east), - ("Washington", "DC", 38.9072, -77.0369, .east), - ("Baltimore", "MD", 39.2904, -76.6122, .east), - ("Miami", "FL", 25.7617, -80.1918, .east), - ("Tampa", "FL", 27.9506, -82.4572, .east), - ("Atlanta", "GA", 33.7490, -84.3880, .east), - ("Charlotte", "NC", 35.2271, -80.8431, .east), - ("Pittsburgh", "PA", 40.4406, -79.9959, .east), - - // Central - ("Chicago", "IL", 41.8781, -87.6298, .central), - ("Detroit", "MI", 42.3314, -83.0458, .central), - ("Cleveland", "OH", 41.4993, -81.6944, .central), - ("Cincinnati", "OH", 39.1031, -84.5120, .central), - ("Milwaukee", "WI", 43.0389, -87.9065, .central), - ("Minneapolis", "MN", 44.9778, -93.2650, .central), - ("St. Louis", "MO", 38.6270, -90.1994, .central), - ("Kansas City", "MO", 39.0997, -94.5786, .central), - ("Dallas", "TX", 32.7767, -96.7970, .central), - ("Houston", "TX", 29.7604, -95.3698, .central), - - // West Coast - ("Los Angeles", "CA", 34.0522, -118.2437, .west), - ("San Francisco", "CA", 37.7749, -122.4194, .west), - ("San Diego", "CA", 32.7157, -117.1611, .west), - ("Seattle", "WA", 47.6062, -122.3321, .west), - ("Portland", "OR", 45.5152, -122.6784, .west), - ("Phoenix", "AZ", 33.4484, -112.0740, .west), - ("Denver", "CO", 39.7392, -104.9903, .west), - ("Salt Lake City", "UT", 40.7608, -111.8910, .west), - ("Las Vegas", "NV", 36.1699, -115.1398, .west), - ("Oakland", "CA", 37.8044, -122.2712, .west), - ] - - private static let teamNames = [ - "Eagles", "Tigers", "Bears", "Lions", "Panthers", - "Hawks", "Wolves", "Sharks", "Dragons", "Knights", - "Royals", "Giants", "Cardinals", "Mariners", "Brewers", - "Rangers", "Padres", "Dodgers", "Mets", "Yankees", - "Cubs", "Sox", "Twins", "Rays", "Marlins", - "Nationals", "Braves", "Reds", "Pirates", "Orioles" - ] - - // MARK: - Generation - - static func generate(with config: Configuration = .default) -> GeneratedData { - var rng = SeededRandomNumberGenerator(seed: config.seed) - - // Generate stadiums - let stadiums = generateStadiums(config: config, rng: &rng) - let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) }) - - // Generate teams (2 per stadium typically) - let teams = generateTeams(stadiums: stadiums, config: config, rng: &rng) - let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) }) - - // Generate games - let games = generateGames(teams: teams, stadiums: stadiums, config: config, rng: &rng) - - return GeneratedData( - stadiums: stadiums, - teams: teams, - games: games, - stadiumsById: stadiumsById, - teamsById: teamsById - ) - } - - private static func generateStadiums( - config: Configuration, - rng: inout SeededRandomNumberGenerator - ) -> [Stadium] { - let cities = selectCities(for: config.geographicSpread, count: config.stadiumCount, rng: &rng) - - return cities.enumerated().map { index, city in - let sport = config.sports.randomElement(using: &rng) ?? .mlb - return Stadium( - id: "stadium_test_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))_\(index)", - name: "\(city.name) \(sport.rawValue) Stadium", - city: city.name, - state: city.state, - latitude: city.lat + Double.random(in: -0.05...0.05, using: &rng), - longitude: city.lon + Double.random(in: -0.05...0.05, using: &rng), - capacity: Int.random(in: 20000...60000, using: &rng), - sport: sport, - yearOpened: Int.random(in: 1990...2024, using: &rng) - ) - } - } - - private static func generateTeams( - stadiums: [Stadium], - config: Configuration, - rng: inout SeededRandomNumberGenerator - ) -> [Team] { - var teams: [Team] = [] - var usedNames = Set() - - for stadium in stadiums { - // Each stadium gets 1-2 teams - let teamCountForStadium = Int.random(in: 1...2, using: &rng) - - for _ in 0.. [Game] { - var games: [Game] = [] - let calendar = Calendar.current - - let dateRangeDays = calendar.dateComponents([.day], from: config.dateRange.lowerBound, to: config.dateRange.upperBound).day ?? 180 - - for _ in 0..= 2 else { break } - - // Pick random home team - let homeTeam = teams.randomElement(using: &rng)! - - // Pick random away team (different from home) - var awayTeam: Team - repeat { - awayTeam = teams.randomElement(using: &rng)! - } while awayTeam.id == homeTeam.id - - // Find home team's stadium - let stadium = stadiums.first { $0.id == homeTeam.stadiumId } ?? stadiums[0] - - // Random date within range - let daysOffset = Int.random(in: 0.. [(name: String, state: String, lat: Double, lon: Double, region: Region)] { - let cities: [(name: String, state: String, lat: Double, lon: Double, region: Region)] - - switch spread { - case .nationwide: - cities = cityData.shuffled(using: &rng) - case .regional: - let region = Region.allCases.randomElement(using: &rng) ?? .east - cities = cityData.filter { $0.region == region }.shuffled(using: &rng) - case .corridor: - // East Coast corridor - cities = cityData.filter { $0.region == .east }.shuffled(using: &rng) - case .cluster: - // Just pick one city and create variations - let baseCity = cityData.randomElement(using: &rng)! - cities = (0.. Trip { - let prefs = preferences ?? TripPreferences( - planningMode: .dateRange, - sports: [.mlb], - startDate: startDate, - endDate: Calendar.current.date(byAdding: .day, value: stops * 2, to: startDate)! - ) - - var tripStops: [TripStop] = [] - var currentDate = startDate - - for i in 0.. [Game] { - cities.enumerated().map { index, city in - let stadiumId = "stadium_conflict_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))_\(index)" - return Game( - id: "game_conflict_\(index)_\(city.name.lowercased().replacingOccurrences(of: " ", with: "_"))", - homeTeamId: "team_conflict_home_\(index)", - awayTeamId: "team_conflict_away_\(index)", - stadiumId: stadiumId, - dateTime: date, - sport: .mlb, - season: "2026" - ) - } - } - - /// Generate a stadium at a specific location - static func makeStadium( - id: String = "stadium_test_\(UUID().uuidString)", - name: String = "Test Stadium", - city: String = "Test City", - state: String = "TS", - latitude: Double = 40.0, - longitude: Double = -100.0, - capacity: Int = 40000, - sport: Sport = .mlb - ) -> Stadium { - Stadium( - id: id, - name: name, - city: city, - state: state, - latitude: latitude, - longitude: longitude, - capacity: capacity, - sport: sport - ) - } - - /// Generate a team - static func makeTeam( - id: String = "team_test_\(UUID().uuidString)", - name: String = "Test Team", - abbreviation: String = "TST", - sport: Sport = .mlb, - city: String = "Test City", - stadiumId: String - ) -> Team { - Team( - id: id, - name: name, - abbreviation: abbreviation, - sport: sport, - city: city, - stadiumId: stadiumId - ) - } - - /// Generate a game - static func makeGame( - id: String = "game_test_\(UUID().uuidString)", - homeTeamId: String, - awayTeamId: String, - stadiumId: String, - dateTime: Date = Date(), - sport: Sport = .mlb, - season: String = "2026", - isPlayoff: Bool = false - ) -> Game { - Game( - id: id, - homeTeamId: homeTeamId, - awayTeamId: awayTeamId, - stadiumId: stadiumId, - dateTime: dateTime, - sport: sport, - season: season, - isPlayoff: isPlayoff - ) - } - - /// Generate a travel segment - static func makeTravelSegment( - from: LocationInput, - to: LocationInput, - distanceMiles: Double = 100, - durationHours: Double = 2 - ) -> TravelSegment { - TravelSegment( - fromLocation: from, - toLocation: to, - travelMode: .drive, - distanceMeters: distanceMiles * TestConstants.metersPerMile, - durationSeconds: durationHours * 3600 - ) - } - - /// Generate a trip stop - static func makeTripStop( - stopNumber: Int = 1, - city: String = "Test City", - state: String = "TS", - coordinate: CLLocationCoordinate2D? = nil, - arrivalDate: Date = Date(), - departureDate: Date? = nil, - games: [String] = [], - stadium: String? = nil, - isRestDay: Bool = false - ) -> TripStop { - TripStop( - stopNumber: stopNumber, - city: city, - state: state, - coordinate: coordinate, - arrivalDate: arrivalDate, - departureDate: departureDate ?? Calendar.current.date(byAdding: .day, value: 1, to: arrivalDate)!, - games: games, - stadium: stadium, - isRestDay: isRestDay - ) - } - - // MARK: - Known Locations for Testing - - struct KnownLocations { - static let nyc = CLLocationCoordinate2D(latitude: 40.7128, longitude: -73.9352) - static let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437) - static let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298) - static let boston = CLLocationCoordinate2D(latitude: 42.3601, longitude: -71.0589) - static let miami = CLLocationCoordinate2D(latitude: 25.7617, longitude: -80.1918) - static let seattle = CLLocationCoordinate2D(latitude: 47.6062, longitude: -122.3321) - static let denver = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) - - // Antipodal point (for testing haversine at extreme distances) - static let antipodal = CLLocationCoordinate2D(latitude: -40.7128, longitude: 106.0648) - } -} diff --git a/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift b/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift deleted file mode 100644 index aca9f66..0000000 --- a/SportsTimeTests/Helpers/BruteForceRouteVerifier.swift +++ /dev/null @@ -1,305 +0,0 @@ -// -// BruteForceRouteVerifier.swift -// SportsTimeTests -// -// Exhaustively enumerates all route permutations to verify optimality. -// Used for inputs with ≤8 stops where brute force is feasible. -// - -import Foundation -import CoreLocation -@testable import SportsTime - -// MARK: - Route Verifier - -struct BruteForceRouteVerifier { - - // MARK: - Route Comparison Result - - struct VerificationResult { - let isOptimal: Bool - let proposedRouteDistance: Double - let optimalRouteDistance: Double - let optimalRoute: [String]? - let improvement: Double? // Percentage improvement if not optimal - let permutationsChecked: Int - - var improvementPercentage: Double? { - guard let improvement = improvement else { return nil } - return improvement * 100 - } - } - - // MARK: - Verification - - /// Verify that a proposed route is optimal (or near-optimal) by checking all permutations - /// - Parameters: - /// - proposedRoute: The route to verify (ordered list of stop IDs) - /// - stops: Dictionary mapping stop IDs to their coordinates - /// - tolerance: Percentage tolerance for "near-optimal" (default 0 = must be exactly optimal) - /// - Returns: Verification result - static func verify( - proposedRoute: [String], - stops: [String: CLLocationCoordinate2D], - tolerance: Double = 0 - ) -> VerificationResult { - guard proposedRoute.count <= TestConstants.bruteForceMaxStops else { - fatalError("BruteForceRouteVerifier should only be used for ≤\(TestConstants.bruteForceMaxStops) stops") - } - - guard proposedRoute.count >= 2 else { - // Single stop or empty - trivially optimal - return VerificationResult( - isOptimal: true, - proposedRouteDistance: 0, - optimalRouteDistance: 0, - optimalRoute: proposedRoute, - improvement: nil, - permutationsChecked: 1 - ) - } - - let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops) - - // Find optimal route by checking all permutations - let allPermutations = permutations(of: proposedRoute) - var optimalDistance = Double.infinity - var optimalRoute: [String] = [] - - for permutation in allPermutations { - let distance = calculateRouteDistance(permutation, stops: stops) - if distance < optimalDistance { - optimalDistance = distance - optimalRoute = permutation - } - } - - let isOptimal: Bool - var improvement: Double? = nil - - if tolerance == 0 { - // Exact optimality check with floating point tolerance - isOptimal = abs(proposedDistance - optimalDistance) < 0.001 - } else { - // Within tolerance - let maxAllowed = optimalDistance * (1 + tolerance) - isOptimal = proposedDistance <= maxAllowed - } - - if !isOptimal && optimalDistance > 0 { - improvement = (proposedDistance - optimalDistance) / optimalDistance - } - - return VerificationResult( - isOptimal: isOptimal, - proposedRouteDistance: proposedDistance, - optimalRouteDistance: optimalDistance, - optimalRoute: optimalRoute, - improvement: improvement, - permutationsChecked: allPermutations.count - ) - } - - /// Verify a route is optimal with a fixed start and end point - static func verifyWithFixedEndpoints( - proposedRoute: [String], - stops: [String: CLLocationCoordinate2D], - startId: String, - endId: String, - tolerance: Double = 0 - ) -> VerificationResult { - guard proposedRoute.first == startId && proposedRoute.last == endId else { - // Invalid route - doesn't match required endpoints - return VerificationResult( - isOptimal: false, - proposedRouteDistance: Double.infinity, - optimalRouteDistance: 0, - optimalRoute: nil, - improvement: nil, - permutationsChecked: 0 - ) - } - - // Get intermediate stops (excluding start and end) - let intermediateStops = proposedRoute.dropFirst().dropLast() - - guard intermediateStops.count <= TestConstants.bruteForceMaxStops - 2 else { - fatalError("BruteForceRouteVerifier: too many intermediate stops") - } - - let proposedDistance = calculateRouteDistance(proposedRoute, stops: stops) - - // Generate all permutations of intermediate stops - let allPermutations = permutations(of: Array(intermediateStops)) - var optimalDistance = Double.infinity - var optimalRoute: [String] = [] - - for permutation in allPermutations { - var fullRoute = [startId] - fullRoute.append(contentsOf: permutation) - fullRoute.append(endId) - - let distance = calculateRouteDistance(fullRoute, stops: stops) - if distance < optimalDistance { - optimalDistance = distance - optimalRoute = fullRoute - } - } - - let isOptimal: Bool - var improvement: Double? = nil - - if tolerance == 0 { - isOptimal = abs(proposedDistance - optimalDistance) < 0.001 - } else { - let maxAllowed = optimalDistance * (1 + tolerance) - isOptimal = proposedDistance <= maxAllowed - } - - if !isOptimal && optimalDistance > 0 { - improvement = (proposedDistance - optimalDistance) / optimalDistance - } - - return VerificationResult( - isOptimal: isOptimal, - proposedRouteDistance: proposedDistance, - optimalRouteDistance: optimalDistance, - optimalRoute: optimalRoute, - improvement: improvement, - permutationsChecked: allPermutations.count - ) - } - - /// Check if there's an obviously better route (significantly shorter) - static func hasObviouslyBetterRoute( - proposedRoute: [String], - stops: [String: CLLocationCoordinate2D], - threshold: Double = 0.1 // 10% improvement threshold - ) -> (hasBetter: Bool, improvement: Double?) { - let result = verify(proposedRoute: proposedRoute, stops: stops, tolerance: threshold) - return (!result.isOptimal, result.improvement) - } - - // MARK: - Distance Calculation - - /// Calculate total route distance using haversine formula - static func calculateRouteDistance( - _ route: [String], - stops: [String: CLLocationCoordinate2D] - ) -> Double { - guard route.count >= 2 else { return 0 } - - var totalDistance: Double = 0 - - for i in 0..<(route.count - 1) { - guard let from = stops[route[i]], - let to = stops[route[i + 1]] else { - continue - } - totalDistance += haversineDistanceMiles(from: from, to: to) - } - - return totalDistance - } - - /// Haversine distance between two coordinates in miles - static func haversineDistanceMiles( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> Double { - let earthRadiusMiles = TestConstants.earthRadiusMiles - - let lat1 = from.latitude * .pi / 180 - let lat2 = to.latitude * .pi / 180 - let deltaLat = (to.latitude - from.latitude) * .pi / 180 - let deltaLon = (to.longitude - from.longitude) * .pi / 180 - - let a = sin(deltaLat / 2) * sin(deltaLat / 2) + - cos(lat1) * cos(lat2) * - sin(deltaLon / 2) * sin(deltaLon / 2) - let c = 2 * atan2(sqrt(a), sqrt(1 - a)) - - return earthRadiusMiles * c - } - - // MARK: - Permutation Generation - - /// Generate all permutations of an array (Heap's algorithm) - static func permutations(of array: [T]) -> [[T]] { - var result: [[T]] = [] - var arr = array - - func generate(_ n: Int) { - if n == 1 { - result.append(arr) - return - } - - for i in 0.. Int { - guard n > 1 else { return 1 } - return (1...n).reduce(1, *) - } -} - -// MARK: - Convenience Extensions - -extension BruteForceRouteVerifier { - /// Verify a trip's route is optimal - static func verifyTrip(_ trip: Trip) -> VerificationResult { - var stops: [String: CLLocationCoordinate2D] = [:] - - for stop in trip.stops { - if let coord = stop.coordinate { - stops[stop.id.uuidString] = coord - } - } - - let routeIds = trip.stops.map { $0.id.uuidString } - return verify(proposedRoute: routeIds, stops: stops) - } - - /// Verify a list of stadiums forms an optimal route - static func verifyStadiumRoute(_ stadiums: [Stadium]) -> VerificationResult { - let stops = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0.coordinate) }) - let routeIds = stadiums.map { $0.id } - return verify(proposedRoute: routeIds, stops: stops) - } -} - -// MARK: - Test Assertions - -extension BruteForceRouteVerifier.VerificationResult { - /// Returns a detailed failure message if not optimal - var failureMessage: String? { - guard !isOptimal else { return nil } - - var message = "Route is not optimal. " - message += "Proposed: \(String(format: "%.1f", proposedRouteDistance)) miles, " - message += "Optimal: \(String(format: "%.1f", optimalRouteDistance)) miles" - - if let improvement = improvementPercentage { - message += " (\(String(format: "%.1f", improvement))% longer)" - } - - message += ". Checked \(permutationsChecked) permutations." - - return message - } -} diff --git a/SportsTimeTests/Helpers/MockServices.swift b/SportsTimeTests/Helpers/MockServices.swift new file mode 100644 index 0000000..2ee70af --- /dev/null +++ b/SportsTimeTests/Helpers/MockServices.swift @@ -0,0 +1,102 @@ +// +// MockServices.swift +// SportsTimeTests +// +// Mock implementations of services for testing. These mocks allow tests +// to control service behavior and verify interactions. +// + +import Foundation +import CoreLocation +@testable import SportsTime + +// MARK: - Mock Data Provider + +/// Mock data provider for testing components that depend on game/stadium/team data. +@MainActor +final class MockDataProvider { + var games: [Game] = [] + var stadiums: [String: Stadium] = [:] + var teams: [String: Team] = [:] + + var shouldFail = false + var failureError: Error = NSError(domain: "MockError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Mock failure"]) + + func configure(games: [Game], stadiums: [Stadium], teams: [Team]) { + self.games = games + self.stadiums = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) }) + self.teams = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) }) + } + + func stadium(for id: String) -> Stadium? { + stadiums[id] + } + + func team(for id: String) -> Team? { + teams[id] + } + + func filterGames(sports: Set, startDate: Date, endDate: Date) throws -> [Game] { + if shouldFail { throw failureError } + return games.filter { game in + sports.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate + } + } +} + +// MARK: - Mock Location Service + +/// Mock location service for testing distance and travel time calculations. +@MainActor +final class MockLocationService { + var stubbedDistances: [String: Double] = [:] // "from_to" -> meters + var stubbedTravelTimes: [String: TimeInterval] = [:] // "from_to" -> seconds + var defaultDistanceMeters: Double = 100_000 // ~62 miles + var defaultTravelTimeSeconds: TimeInterval = 3600 // 1 hour + + var shouldFail = false + var failureError: Error = NSError(domain: "LocationError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Location unavailable"]) + + var calculateDistanceCalls: [(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D)] = [] + var calculateTravelTimeCalls: [(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D)] = [] + + func stubDistance(from: String, to: String, meters: Double) { + stubbedDistances["\(from)_\(to)"] = meters + } + + func stubTravelTime(from: String, to: String, seconds: TimeInterval) { + stubbedTravelTimes["\(from)_\(to)"] = seconds + } + + func calculateDistance(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> Double { + calculateDistanceCalls.append((from: from, to: to)) + if shouldFail { throw failureError } + return defaultDistanceMeters + } + + func calculateTravelTime(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async throws -> TimeInterval { + calculateTravelTimeCalls.append((from: from, to: to)) + if shouldFail { throw failureError } + return defaultTravelTimeSeconds + } +} + +// MARK: - Mock Route Service + +/// Mock route service for testing route optimization. +@MainActor +final class MockRouteService { + var shouldFail = false + var failureError: Error = NSError(domain: "RouteError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Route unavailable"]) + + var optimizeRouteCalls: [[CLLocationCoordinate2D]] = [] + var stubbedRoute: [Int]? // Indices in order + + func optimizeRoute(waypoints: [CLLocationCoordinate2D]) async throws -> [Int] { + optimizeRouteCalls.append(waypoints) + if shouldFail { throw failureError } + return stubbedRoute ?? Array(0.. Game { + let actualDateTime = dateTime ?? Calendar.current.date(byAdding: .day, value: 1, to: Date())! + let homeId = homeTeamId ?? "team_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))" + let awayId = awayTeamId ?? "team_\(sport.rawValue.lowercased())_visitor" + let stadId = stadiumId ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))" + + let formatter = DateFormatter() + formatter.dateFormat = "MMdd" + let dateStr = formatter.string(from: actualDateTime) + + let actualId = id ?? "game_\(sport.rawValue.lowercased())_\(season)_\(awayId.split(separator: "_").last ?? "vis")_\(homeId.split(separator: "_").last ?? "home")_\(dateStr)" + + return Game( + id: actualId, + homeTeamId: homeId, + awayTeamId: awayId, + stadiumId: stadId, + dateTime: actualDateTime, + sport: sport, + season: season, + isPlayoff: isPlayoff + ) + } + + /// Creates multiple games spread across time and cities. + /// + /// - Parameter count: Number of games to create + /// - Parameter cities: Cities to distribute games across (cycles through list) + /// - Parameter startDate: First game date (subsequent games spread by daySpread) + /// - Parameter daySpread: Days between games + static func games( + count: Int, + sport: Sport = .mlb, + cities: [String] = ["New York", "Boston", "Chicago", "Los Angeles"], + startDate: Date = Date(), + daySpread: Int = 1 + ) -> [Game] { + (0.. [Game] { + cities.enumerated().map { index, city in + // Stagger times by 3 hours + let time = Calendar.current.date(byAdding: .hour, value: 13 + (index * 3), to: Calendar.current.startOfDay(for: date))! + return game(sport: sport, city: city, dateTime: time) + } + } + + // MARK: - Stadium Factory + + /// Creates a Stadium with realistic defaults. + /// + /// - Expected Behavior: + /// - Uses real coordinates for known cities + /// - ID follows canonical format: "stadium_{sport}_{city}" + /// - TimeZone populated for known cities + static func stadium( + id: String? = nil, + name: String? = nil, + city: String = "New York", + state: String? = nil, + sport: Sport = .mlb, + capacity: Int = 40000, + yearOpened: Int? = nil + ) -> Stadium { + let coordinate = coordinates[city] ?? CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0) + let actualState = state ?? states[city] ?? "NY" + let actualName = name ?? "\(city) \(sport.rawValue) Stadium" + let actualId = id ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))" + + return Stadium( + id: actualId, + name: actualName, + city: city, + state: actualState, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + capacity: capacity, + sport: sport, + yearOpened: yearOpened, + timeZoneIdentifier: timeZones[city] + ) + } + + /// Creates a stadium map for a set of games. + static func stadiumMap(for games: [Game]) -> [String: Stadium] { + var map: [String: Stadium] = [:] + for game in games { + if map[game.stadiumId] == nil { + // Extract city from stadium ID (assumes format stadium_sport_city) + let parts = game.stadiumId.split(separator: "_") + let city = parts.count > 2 ? parts[2...].joined(separator: " ").capitalized : "Unknown" + map[game.stadiumId] = stadium(id: game.stadiumId, city: city, sport: game.sport) + } + } + return map + } + + /// Creates stadiums at specific coordinates for distance testing. + static func stadiumsForDistanceTest() -> [Stadium] { + [ + stadium(city: "New York"), // East + stadium(city: "Chicago"), // Central + stadium(city: "Denver"), // Mountain + stadium(city: "Los Angeles"), // West + ] + } + + // MARK: - Team Factory + + /// Creates a Team with realistic defaults. + static func team( + id: String? = nil, + name: String = "Test Team", + abbreviation: String? = nil, + sport: Sport = .mlb, + city: String = "New York", + stadiumId: String? = nil + ) -> Team { + let actualId = id ?? "team_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))" + let actualAbbr = abbreviation ?? String(city.prefix(3)).uppercased() + let actualStadiumId = stadiumId ?? "stadium_\(sport.rawValue.lowercased())_\(city.lowercased().replacingOccurrences(of: " ", with: "_"))" + + return Team( + id: actualId, + name: name, + abbreviation: actualAbbr, + sport: sport, + city: city, + stadiumId: actualStadiumId + ) + } + + // MARK: - TripStop Factory + + /// Creates a TripStop with realistic defaults. + static func tripStop( + stopNumber: Int = 1, + city: String = "New York", + state: String? = nil, + arrivalDate: Date? = nil, + departureDate: Date? = nil, + games: [String] = [], + isRestDay: Bool = false + ) -> TripStop { + let coordinate = coordinates[city] + let actualState = state ?? states[city] ?? "NY" + let arrival = arrivalDate ?? Date() + let departure = departureDate ?? Calendar.current.date(byAdding: .day, value: 1, to: arrival)! + + return TripStop( + stopNumber: stopNumber, + city: city, + state: actualState, + coordinate: coordinate, + arrivalDate: arrival, + departureDate: departure, + games: games, + isRestDay: isRestDay + ) + } + + /// Creates a sequence of trip stops for a multi-city trip. + static func tripStops( + cities: [String], + startDate: Date = Date(), + daysPerStop: Int = 1 + ) -> [TripStop] { + var stops: [TripStop] = [] + var currentDate = startDate + + for (index, city) in cities.enumerated() { + let departure = Calendar.current.date(byAdding: .day, value: daysPerStop, to: currentDate)! + stops.append(tripStop( + stopNumber: index + 1, + city: city, + arrivalDate: currentDate, + departureDate: departure + )) + currentDate = departure + } + return stops + } + + // MARK: - TravelSegment Factory + + /// Creates a TravelSegment between two cities. + static func travelSegment( + from: String = "New York", + to: String = "Boston", + travelMode: TravelMode = .drive + ) -> TravelSegment { + let fromCoord = coordinates[from] ?? CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0) + let toCoord = coordinates[to] ?? CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0) + + // Calculate approximate distance (haversine) + let distance = haversineDistance(from: fromCoord, to: toCoord) + // Estimate driving time at 60 mph average + let duration = distance / 60.0 * 3600.0 + + return TravelSegment( + fromLocation: LocationInput(name: from, coordinate: fromCoord), + toLocation: LocationInput(name: to, coordinate: toCoord), + travelMode: travelMode, + distanceMeters: distance * 1609.34, // miles to meters + durationSeconds: duration + ) + } + + // MARK: - TripPreferences Factory + + /// Creates TripPreferences with common defaults. + static func preferences( + mode: PlanningMode = .dateRange, + sports: Set = [.mlb], + startDate: Date? = nil, + endDate: Date? = nil, + regions: Set = [.east, .central, .west], + leisureLevel: LeisureLevel = .moderate, + travelMode: TravelMode = .drive, + needsEVCharging: Bool = false, + maxDrivingHoursPerDriver: Double? = nil + ) -> TripPreferences { + let start = startDate ?? Date() + let end = endDate ?? Calendar.current.date(byAdding: .day, value: 7, to: start)! + + return TripPreferences( + planningMode: mode, + sports: sports, + travelMode: travelMode, + startDate: start, + endDate: end, + leisureLevel: leisureLevel, + routePreference: .balanced, + needsEVCharging: needsEVCharging, + maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, + selectedRegions: regions + ) + } + + // MARK: - Trip Factory + + /// Creates a complete Trip with stops and segments. + static func trip( + name: String = "Test Trip", + stops: [TripStop]? = nil, + preferences: TripPreferences? = nil, + status: TripStatus = .planned + ) -> Trip { + let actualStops = stops ?? tripStops(cities: ["New York", "Boston"]) + let actualPrefs = preferences ?? TestFixtures.preferences() + + // Calculate totals from stops + let totalGames = actualStops.reduce(0) { $0 + $1.games.count } + + return Trip( + name: name, + preferences: actualPrefs, + stops: actualStops, + totalGames: totalGames, + status: status + ) + } + + // MARK: - RichGame Factory + + /// Creates a RichGame with resolved team and stadium references. + static func richGame( + game: Game? = nil, + homeCity: String = "New York", + awayCity: String = "Boston", + sport: Sport = .mlb + ) -> RichGame { + let actualGame = game ?? TestFixtures.game(sport: sport, city: homeCity) + let homeTeam = team(sport: sport, city: homeCity) + let awayTeam = team(sport: sport, city: awayCity) + let gameStadium = stadium(city: homeCity, sport: sport) + + return RichGame( + game: actualGame, + homeTeam: homeTeam, + awayTeam: awayTeam, + stadium: gameStadium + ) + } + + // MARK: - TripScore Factory + + /// Creates a TripScore with customizable component scores. + static func tripScore( + overall: Double = 85.0, + gameQuality: Double = 90.0, + routeEfficiency: Double = 80.0, + leisureBalance: Double = 85.0, + preferenceAlignment: Double = 85.0 + ) -> TripScore { + TripScore( + overallScore: overall, + gameQualityScore: gameQuality, + routeEfficiencyScore: routeEfficiency, + leisureBalanceScore: leisureBalance, + preferenceAlignmentScore: preferenceAlignment + ) + } + + // MARK: - Date Helpers + + /// Creates a date at a specific time (for testing time-sensitive logic). + static func date( + year: Int = 2026, + month: Int = 6, + day: Int = 15, + hour: Int = 19, + minute: Int = 5 + ) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.timeZone = TimeZone(identifier: "America/New_York") + return Calendar.current.date(from: components)! + } + + /// Creates dates for a range of days. + static func dateRange(start: Date = Date(), days: Int) -> (start: Date, end: Date) { + let end = Calendar.current.date(byAdding: .day, value: days, to: start)! + return (start, end) + } + + // MARK: - Private Helpers + + /// Haversine distance calculation (returns miles). + private static func haversineDistance( + from: CLLocationCoordinate2D, + to: CLLocationCoordinate2D + ) -> Double { + let R = 3958.8 // Earth radius in miles + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let deltaLat = (to.latitude - from.latitude) * .pi / 180 + let deltaLon = (to.longitude - from.longitude) * .pi / 180 + + let a = sin(deltaLat / 2) * sin(deltaLat / 2) + + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return R * c + } +} + +// MARK: - Coordinate Constants for Testing + +extension TestFixtures { + + /// Known distances between cities (in miles) for validation. + static let knownDistances: [(from: String, to: String, miles: Double)] = [ + ("New York", "Boston", 215), + ("New York", "Chicago", 790), + ("New York", "Los Angeles", 2790), + ("Chicago", "Denver", 1000), + ("Los Angeles", "San Francisco", 380), + ("Seattle", "Los Angeles", 1135), + ] + + /// Cities clearly in each region for boundary testing. + static let eastCoastCities = ["New York", "Boston", "Miami", "Atlanta", "Philadelphia"] + static let centralCities = ["Chicago", "Houston", "Dallas", "Minneapolis", "Detroit"] + static let westCoastCities = ["Los Angeles", "San Francisco", "Seattle", "Phoenix"] +} diff --git a/SportsTimeTests/Loading/LoadingPlaceholderTests.swift b/SportsTimeTests/Loading/LoadingPlaceholderTests.swift deleted file mode 100644 index 42da932..0000000 --- a/SportsTimeTests/Loading/LoadingPlaceholderTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// LoadingPlaceholderTests.swift -// SportsTimeTests -// - -import Testing -import SwiftUI -@testable import SportsTime - -struct LoadingPlaceholderTests { - - @Test func rectangleHasCorrectDimensions() { - let rect = LoadingPlaceholder.rectangle(width: 100, height: 20) - #expect(rect.width == 100) - #expect(rect.height == 20) - } - - @Test func circleHasCorrectDiameter() { - let circle = LoadingPlaceholder.circle(diameter: 40) - #expect(circle.diameter == 40) - } - - @Test func capsuleHasCorrectDimensions() { - let capsule = LoadingPlaceholder.capsule(width: 80, height: 24) - #expect(capsule.width == 80) - #expect(capsule.height == 24) - } - - @Test func animationCycleDurationIsCorrect() { - #expect(LoadingPlaceholder.animationDuration == 1.2) - } - - @Test func opacityRangeIsSubtle() { - #expect(LoadingPlaceholder.minOpacity == 0.3) - #expect(LoadingPlaceholder.maxOpacity == 0.5) - } -} diff --git a/SportsTimeTests/Loading/LoadingSheetTests.swift b/SportsTimeTests/Loading/LoadingSheetTests.swift deleted file mode 100644 index 955caa1..0000000 --- a/SportsTimeTests/Loading/LoadingSheetTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LoadingSheetTests.swift -// SportsTimeTests -// - -import Testing -import SwiftUI -@testable import SportsTime - -struct LoadingSheetTests { - - @Test func sheetRequiresLabel() { - let sheet = LoadingSheet(label: "Planning trip") - #expect(sheet.label == "Planning trip") - } - - @Test func sheetCanHaveOptionalDetail() { - let withDetail = LoadingSheet(label: "Exporting", detail: "Generating maps...") - let withoutDetail = LoadingSheet(label: "Loading") - - #expect(withDetail.detail == "Generating maps...") - #expect(withoutDetail.detail == nil) - } - - @Test func backgroundOpacityIsCorrect() { - #expect(LoadingSheet.backgroundOpacity == 0.5) - } -} diff --git a/SportsTimeTests/Loading/LoadingSpinnerTests.swift b/SportsTimeTests/Loading/LoadingSpinnerTests.swift deleted file mode 100644 index 91ad50c..0000000 --- a/SportsTimeTests/Loading/LoadingSpinnerTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// LoadingSpinnerTests.swift -// SportsTimeTests -// - -import Testing -import SwiftUI -@testable import SportsTime - -struct LoadingSpinnerTests { - - @Test func smallSizeHasCorrectDimensions() { - let config = LoadingSpinner.Size.small - #expect(config.diameter == 16) - #expect(config.strokeWidth == 2) - } - - @Test func mediumSizeHasCorrectDimensions() { - let config = LoadingSpinner.Size.medium - #expect(config.diameter == 24) - #expect(config.strokeWidth == 3) - } - - @Test func largeSizeHasCorrectDimensions() { - let config = LoadingSpinner.Size.large - #expect(config.diameter == 40) - #expect(config.strokeWidth == 4) - } - - @Test func spinnerCanBeCreatedWithAllSizes() { - let small = LoadingSpinner(size: .small) - let medium = LoadingSpinner(size: .medium) - let large = LoadingSpinner(size: .large) - - #expect(small.size == .small) - #expect(medium.size == .medium) - #expect(large.size == .large) - } - - @Test func spinnerCanHaveOptionalLabel() { - let withLabel = LoadingSpinner(size: .medium, label: "Loading...") - let withoutLabel = LoadingSpinner(size: .medium) - - #expect(withLabel.label == "Loading...") - #expect(withoutLabel.label == nil) - } -} diff --git a/SportsTimeTests/Mocks/MockAppDataProvider.swift b/SportsTimeTests/Mocks/MockAppDataProvider.swift deleted file mode 100644 index f737bb1..0000000 --- a/SportsTimeTests/Mocks/MockAppDataProvider.swift +++ /dev/null @@ -1,319 +0,0 @@ -// -// MockAppDataProvider.swift -// SportsTimeTests -// -// Mock implementation of AppDataProvider for testing without SwiftData dependencies. -// - -import Foundation -import Combine -@testable import SportsTime - -// MARK: - Mock App Data Provider - -@MainActor -final class MockAppDataProvider: ObservableObject { - - // MARK: - Published State - - @Published private(set) var teams: [Team] = [] - @Published private(set) var stadiums: [Stadium] = [] - @Published private(set) var dynamicSports: [DynamicSport] = [] - @Published private(set) var isLoading = false - @Published private(set) var error: Error? - @Published private(set) var errorMessage: String? - - // MARK: - Internal Storage - - private var teamsById: [String: Team] = [:] - private var stadiumsById: [String: Stadium] = [:] - private var dynamicSportsById: [String: DynamicSport] = [:] - private var games: [Game] = [] - private var gamesById: [String: Game] = [:] - - // MARK: - Configuration - - struct Configuration { - var simulatedLatency: TimeInterval = 0 - var shouldFailOnLoad: Bool = false - var shouldFailOnFetch: Bool = false - var isEmpty: Bool = false - - static var `default`: Configuration { Configuration() } - static var empty: Configuration { Configuration(isEmpty: true) } - static var failing: Configuration { Configuration(shouldFailOnLoad: true) } - static var slow: Configuration { Configuration(simulatedLatency: 1.0) } - } - - private var config: Configuration - - // MARK: - Call Tracking - - private(set) var loadInitialDataCallCount = 0 - private(set) var filterGamesCallCount = 0 - private(set) var filterRichGamesCallCount = 0 - private(set) var allGamesCallCount = 0 - private(set) var allRichGamesCallCount = 0 - - // MARK: - Initialization - - init(config: Configuration = .default) { - self.config = config - } - - // MARK: - Configuration Methods - - func configure(_ newConfig: Configuration) { - self.config = newConfig - } - - func setTeams(_ newTeams: [Team]) { - self.teams = newTeams - self.teamsById = Dictionary(uniqueKeysWithValues: newTeams.map { ($0.id, $0) }) - } - - func setStadiums(_ newStadiums: [Stadium]) { - self.stadiums = newStadiums - self.stadiumsById = Dictionary(uniqueKeysWithValues: newStadiums.map { ($0.id, $0) }) - } - - func setGames(_ newGames: [Game]) { - self.games = newGames - self.gamesById = Dictionary(uniqueKeysWithValues: newGames.map { ($0.id, $0) }) - } - - func setDynamicSports(_ newSports: [DynamicSport]) { - self.dynamicSports = newSports - self.dynamicSportsById = Dictionary(uniqueKeysWithValues: newSports.map { ($0.id, $0) }) - } - - func reset() { - teams = [] - stadiums = [] - dynamicSports = [] - games = [] - teamsById = [:] - stadiumsById = [:] - dynamicSportsById = [:] - gamesById = [:] - isLoading = false - error = nil - errorMessage = nil - loadInitialDataCallCount = 0 - filterGamesCallCount = 0 - filterRichGamesCallCount = 0 - allGamesCallCount = 0 - allRichGamesCallCount = 0 - config = .default - } - - // MARK: - Simulated Network - - private func simulateLatency() async { - if config.simulatedLatency > 0 { - try? await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) - } - } - - // MARK: - Data Loading - - func loadInitialData() async { - loadInitialDataCallCount += 1 - - if config.isEmpty { - teams = [] - stadiums = [] - return - } - - isLoading = true - error = nil - errorMessage = nil - - await simulateLatency() - - if config.shouldFailOnLoad { - error = DataProviderError.contextNotConfigured - errorMessage = "Mock load failure" - isLoading = false - return - } - - isLoading = false - } - - func clearError() { - error = nil - errorMessage = nil - } - - func retry() async { - await loadInitialData() - } - - // MARK: - Data Access - - func team(for id: String) -> Team? { - teamsById[id] - } - - func stadium(for id: String) -> Stadium? { - stadiumsById[id] - } - - func teams(for sport: Sport) -> [Team] { - teams.filter { $0.sport == sport } - } - - func dynamicSport(for id: String) -> DynamicSport? { - dynamicSportsById[id] - } - - /// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports - var allSports: [any AnySport] { - let builtIn: [any AnySport] = Sport.allCases - let dynamic: [any AnySport] = dynamicSports - return builtIn + dynamic - } - - // MARK: - Game Filtering (Local Queries) - - func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { - filterGamesCallCount += 1 - await simulateLatency() - - if config.shouldFailOnFetch { - throw DataProviderError.contextNotConfigured - } - - return games.filter { game in - sports.contains(game.sport) && - game.dateTime >= startDate && - game.dateTime <= endDate - }.sorted { $0.dateTime < $1.dateTime } - } - - func allGames(for sports: Set) async throws -> [Game] { - allGamesCallCount += 1 - await simulateLatency() - - if config.shouldFailOnFetch { - throw DataProviderError.contextNotConfigured - } - - return games.filter { game in - sports.contains(game.sport) - }.sorted { $0.dateTime < $1.dateTime } - } - - func fetchGame(by id: String) async throws -> Game? { - await simulateLatency() - - if config.shouldFailOnFetch { - throw DataProviderError.contextNotConfigured - } - - return gamesById[id] - } - - func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { - filterRichGamesCallCount += 1 - let filteredGames = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) - - return filteredGames.compactMap { game in - richGame(from: game) - } - } - - func allRichGames(for sports: Set) async throws -> [RichGame] { - allRichGamesCallCount += 1 - let allFilteredGames = try await allGames(for: sports) - - return allFilteredGames.compactMap { game in - richGame(from: game) - } - } - - func richGame(from game: Game) -> RichGame? { - guard let homeTeam = teamsById[game.homeTeamId], - let awayTeam = teamsById[game.awayTeamId], - let stadium = stadiumsById[game.stadiumId] else { - return nil - } - return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) - } -} - -// MARK: - Convenience Extensions - -extension MockAppDataProvider { - /// Load fixture data from FixtureGenerator - func loadFixtures(_ data: FixtureGenerator.GeneratedData) { - setTeams(data.teams) - setStadiums(data.stadiums) - setGames(data.games) - } - - /// Create a mock provider with fixture data pre-loaded - static func withFixtures(_ config: FixtureGenerator.Configuration = .default) -> MockAppDataProvider { - let mock = MockAppDataProvider() - let data = FixtureGenerator.generate(with: config) - mock.loadFixtures(data) - return mock - } - - /// Create a mock provider configured as empty - static var empty: MockAppDataProvider { - MockAppDataProvider(config: .empty) - } - - /// Create a mock provider configured to fail - static var failing: MockAppDataProvider { - MockAppDataProvider(config: .failing) - } -} - -// MARK: - Test Helpers - -extension MockAppDataProvider { - /// Add a single game - func addGame(_ game: Game) { - games.append(game) - gamesById[game.id] = game - } - - /// Add a single team - func addTeam(_ team: Team) { - teams.append(team) - teamsById[team.id] = team - } - - /// Add a single stadium - func addStadium(_ stadium: Stadium) { - stadiums.append(stadium) - stadiumsById[stadium.id] = stadium - } - - /// Get all stored games (for test verification) - func getAllStoredGames() -> [Game] { - games - } - - /// Get games count - var gamesCount: Int { games.count } - - /// Get teams count - var teamsCount: Int { teams.count } - - /// Get stadiums count - var stadiumsCount: Int { stadiums.count } - - /// Add a single dynamic sport - func addDynamicSport(_ sport: DynamicSport) { - dynamicSports.append(sport) - dynamicSportsById[sport.id] = sport - } - - /// Get dynamic sports count - var dynamicSportsCount: Int { dynamicSports.count } -} diff --git a/SportsTimeTests/Mocks/MockCloudKitService.swift b/SportsTimeTests/Mocks/MockCloudKitService.swift deleted file mode 100644 index 6149c4c..0000000 --- a/SportsTimeTests/Mocks/MockCloudKitService.swift +++ /dev/null @@ -1,294 +0,0 @@ -// -// MockCloudKitService.swift -// SportsTimeTests -// -// Mock implementation of CloudKitService for testing without network dependencies. -// - -import Foundation -@testable import SportsTime - -// MARK: - Mock CloudKit Service - -actor MockCloudKitService { - - // MARK: - Configuration - - struct Configuration { - var isAvailable: Bool = true - var simulatedLatency: TimeInterval = 0 - var shouldFailWithError: CloudKitError? = nil - var errorAfterNCalls: Int? = nil - - static var `default`: Configuration { Configuration() } - static var offline: Configuration { Configuration(isAvailable: false) } - static var slow: Configuration { Configuration(simulatedLatency: 2.0) } - } - - // MARK: - Stored Data - - private var stadiums: [Stadium] = [] - private var teams: [Team] = [] - private var games: [Game] = [] - private var leagueStructure: [LeagueStructureModel] = [] - private var teamAliases: [TeamAlias] = [] - private var stadiumAliases: [StadiumAlias] = [] - - // MARK: - Call Tracking - - private(set) var fetchStadiumsCallCount = 0 - private(set) var fetchTeamsCallCount = 0 - private(set) var fetchGamesCallCount = 0 - private(set) var isAvailableCallCount = 0 - - // MARK: - Configuration - - private var config: Configuration - - // MARK: - Initialization - - init(config: Configuration = .default) { - self.config = config - } - - // MARK: - Configuration Methods - - func configure(_ newConfig: Configuration) { - self.config = newConfig - } - - func setStadiums(_ stadiums: [Stadium]) { - self.stadiums = stadiums - } - - func setTeams(_ teams: [Team]) { - self.teams = teams - } - - func setGames(_ games: [Game]) { - self.games = games - } - - func setLeagueStructure(_ structure: [LeagueStructureModel]) { - self.leagueStructure = structure - } - - func reset() { - stadiums = [] - teams = [] - games = [] - leagueStructure = [] - teamAliases = [] - stadiumAliases = [] - fetchStadiumsCallCount = 0 - fetchTeamsCallCount = 0 - fetchGamesCallCount = 0 - isAvailableCallCount = 0 - config = .default - } - - // MARK: - Simulated Network - - private func simulateNetwork() async throws { - // Simulate latency - if config.simulatedLatency > 0 { - try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) - } - - // Check for configured error - if let error = config.shouldFailWithError { - throw error - } - } - - private func checkErrorAfterNCalls(_ callCount: Int) throws { - if let errorAfterN = config.errorAfterNCalls, callCount >= errorAfterN { - throw config.shouldFailWithError ?? CloudKitError.networkUnavailable - } - } - - // MARK: - Availability - - func isAvailable() async -> Bool { - isAvailableCallCount += 1 - return config.isAvailable - } - - func checkAvailabilityWithError() async throws { - if !config.isAvailable { - throw CloudKitError.networkUnavailable - } - if let error = config.shouldFailWithError { - throw error - } - } - - // MARK: - Fetch Operations - - func fetchStadiums() async throws -> [Stadium] { - fetchStadiumsCallCount += 1 - try checkErrorAfterNCalls(fetchStadiumsCallCount) - try await simulateNetwork() - return stadiums - } - - func fetchTeams(for sport: Sport) async throws -> [Team] { - fetchTeamsCallCount += 1 - try checkErrorAfterNCalls(fetchTeamsCallCount) - try await simulateNetwork() - return teams.filter { $0.sport == sport } - } - - func fetchGames( - sports: Set, - startDate: Date, - endDate: Date - ) async throws -> [Game] { - fetchGamesCallCount += 1 - try checkErrorAfterNCalls(fetchGamesCallCount) - try await simulateNetwork() - - return games.filter { game in - sports.contains(game.sport) && - game.dateTime >= startDate && - game.dateTime <= endDate - }.sorted { $0.dateTime < $1.dateTime } - } - - func fetchGame(by id: String) async throws -> Game? { - try await simulateNetwork() - return games.first { $0.id == id } - } - - // MARK: - Sync Fetch Methods (Delta Sync Pattern) - - /// Fetch stadiums for sync - returns all if lastSync is nil, otherwise filters by modification date - func fetchStadiumsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncStadium] { - try await simulateNetwork() - // Mock doesn't track modification dates, so return all stadiums - // (In production, CloudKit filters by modificationDate) - return stadiums.map { stadium in - CloudKitService.SyncStadium( - stadium: stadium, - canonicalId: stadium.id - ) - } - } - - /// Fetch teams for sync - returns all if lastSync is nil, otherwise filters by modification date - func fetchTeamsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncTeam] { - try await simulateNetwork() - // Mock doesn't track modification dates, so return all teams - // (In production, CloudKit filters by modificationDate) - return teams.map { team in - CloudKitService.SyncTeam( - team: team, - canonicalId: team.id, - stadiumCanonicalId: team.stadiumId - ) - } - } - - /// Fetch games for sync - returns all if lastSync is nil, otherwise filters by modification date - func fetchGamesForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncGame] { - try await simulateNetwork() - // Mock doesn't track modification dates, so return all games - // (In production, CloudKit filters by modificationDate) - return games.map { game in - CloudKitService.SyncGame( - game: game, - canonicalId: game.id, - homeTeamCanonicalId: game.homeTeamId, - awayTeamCanonicalId: game.awayTeamId, - stadiumCanonicalId: game.stadiumId - ) - } - } - - // MARK: - League Structure & Aliases - - func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] { - try await simulateNetwork() - if let sport = sport { - return leagueStructure.filter { $0.sport == sport.rawValue } - } - return leagueStructure - } - - func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] { - try await simulateNetwork() - if let teamId = teamCanonicalId { - return teamAliases.filter { $0.teamCanonicalId == teamId } - } - return teamAliases - } - - func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] { - try await simulateNetwork() - if let stadiumId = stadiumCanonicalId { - return stadiumAliases.filter { $0.stadiumCanonicalId == stadiumId } - } - return stadiumAliases - } - - // MARK: - Delta Sync - - func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] { - try await simulateNetwork() - guard let lastSync = lastSync else { - return leagueStructure - } - return leagueStructure.filter { $0.lastModified > lastSync } - } - - func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] { - try await simulateNetwork() - guard let lastSync = lastSync else { - return teamAliases - } - return teamAliases.filter { $0.lastModified > lastSync } - } - - func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] { - try await simulateNetwork() - guard let lastSync = lastSync else { - return stadiumAliases - } - return stadiumAliases.filter { $0.lastModified > lastSync } - } - - // MARK: - Subscriptions (No-ops for testing) - - func subscribeToScheduleUpdates() async throws {} - func subscribeToLeagueStructureUpdates() async throws {} - func subscribeToTeamAliasUpdates() async throws {} - func subscribeToStadiumAliasUpdates() async throws {} - func subscribeToAllUpdates() async throws {} -} - -// MARK: - Convenience Extensions - -extension MockCloudKitService { - /// Load fixture data from FixtureGenerator - func loadFixtures(_ data: FixtureGenerator.GeneratedData) { - Task { - await setStadiums(data.stadiums) - await setTeams(data.teams) - await setGames(data.games) - } - } - - /// Configure to simulate specific error scenarios - static func withError(_ error: CloudKitError) -> MockCloudKitService { - let mock = MockCloudKitService() - Task { - await mock.configure(Configuration(shouldFailWithError: error)) - } - return mock - } - - /// Configure to be offline - static var offline: MockCloudKitService { - MockCloudKitService(config: .offline) - } -} diff --git a/SportsTimeTests/Mocks/MockData+Polls.swift b/SportsTimeTests/Mocks/MockData+Polls.swift deleted file mode 100644 index 8a8718f..0000000 --- a/SportsTimeTests/Mocks/MockData+Polls.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// MockData+Polls.swift -// SportsTimeTests -// -// Mock data extensions for poll-related tests -// - -import Foundation -@testable import SportsTime - -// MARK: - Trip Mock - -extension Trip { - /// Creates a mock trip for testing - static func mock( - id: UUID = UUID(), - name: String = "Test Trip", - cities: [String] = ["Boston", "New York"], - startDate: Date = Date(), - games: [String] = [] - ) -> Trip { - let stops = cities.enumerated().map { index, city in - TripStop.mock( - stopNumber: index + 1, - city: city, - arrivalDate: startDate.addingTimeInterval(Double(index) * 86400), - departureDate: startDate.addingTimeInterval(Double(index + 1) * 86400), - games: games - ) - } - - return Trip( - id: id, - name: name, - preferences: TripPreferences( - planningMode: .dateRange, - sports: [.mlb], - startDate: startDate, - endDate: startDate.addingTimeInterval(86400 * Double(cities.count)) - ), - stops: stops - ) - } -} - -// MARK: - TripStop Mock - -extension TripStop { - /// Creates a mock trip stop for testing - static func mock( - stopNumber: Int = 1, - city: String = "Boston", - state: String = "MA", - arrivalDate: Date = Date(), - departureDate: Date? = nil, - games: [String] = [] - ) -> TripStop { - TripStop( - stopNumber: stopNumber, - city: city, - state: state, - arrivalDate: arrivalDate, - departureDate: departureDate ?? arrivalDate.addingTimeInterval(86400), - games: games - ) - } -} - -// MARK: - TripPoll Mock - -extension TripPoll { - /// Creates a mock poll for testing - static func mock( - id: UUID = UUID(), - title: String = "Test Poll", - ownerId: String = "mockOwner", - shareCode: String? = nil, - tripCount: Int = 2, - trips: [Trip]? = nil - ) -> TripPoll { - let tripSnapshots = trips ?? (0.. PollVote { - PollVote( - id: id, - pollId: pollId, - odg: odg, - rankings: rankings - ) - } -} - -// MARK: - PollResults Mock - -extension PollResults { - /// Creates mock results for testing - static func mock( - poll: TripPoll? = nil, - votes: [PollVote]? = nil - ) -> PollResults { - let testPoll = poll ?? TripPoll.mock() - let testVotes = votes ?? [ - PollVote.mock(pollId: testPoll.id, odg: "voter1", rankings: [0, 1]), - PollVote.mock(pollId: testPoll.id, odg: "voter2", rankings: [1, 0]) - ] - - return PollResults(poll: testPoll, votes: testVotes) - } -} diff --git a/SportsTimeTests/Mocks/MockLocationService.swift b/SportsTimeTests/Mocks/MockLocationService.swift deleted file mode 100644 index 810c54f..0000000 --- a/SportsTimeTests/Mocks/MockLocationService.swift +++ /dev/null @@ -1,296 +0,0 @@ -// -// MockLocationService.swift -// SportsTimeTests -// -// Mock implementation of LocationService for testing without MapKit dependencies. -// - -import Foundation -import CoreLocation -import MapKit -@testable import SportsTime - -// MARK: - Mock Location Service - -actor MockLocationService { - - // MARK: - Configuration - - struct Configuration { - var simulatedLatency: TimeInterval = 0 - var shouldFailGeocode: Bool = false - var shouldFailRoute: Bool = false - var defaultDrivingSpeedMPH: Double = 60.0 - var useHaversineForDistance: Bool = true - - static var `default`: Configuration { Configuration() } - static var slow: Configuration { Configuration(simulatedLatency: 1.0) } - static var failingGeocode: Configuration { Configuration(shouldFailGeocode: true) } - static var failingRoute: Configuration { Configuration(shouldFailRoute: true) } - } - - // MARK: - Pre-configured Responses - - private var geocodeResponses: [String: CLLocationCoordinate2D] = [:] - private var routeResponses: [String: RouteInfo] = [:] - - // MARK: - Call Tracking - - private(set) var geocodeCallCount = 0 - private(set) var reverseGeocodeCallCount = 0 - private(set) var calculateRouteCallCount = 0 - private(set) var searchLocationsCallCount = 0 - - // MARK: - Configuration - - private var config: Configuration - - // MARK: - Initialization - - init(config: Configuration = .default) { - self.config = config - } - - // MARK: - Configuration Methods - - func configure(_ newConfig: Configuration) { - self.config = newConfig - } - - func setGeocodeResponse(for address: String, coordinate: CLLocationCoordinate2D) { - geocodeResponses[address.lowercased()] = coordinate - } - - func setRouteResponse(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D, route: RouteInfo) { - let key = routeKey(from: from, to: to) - routeResponses[key] = route - } - - func reset() { - geocodeResponses = [:] - routeResponses = [:] - geocodeCallCount = 0 - reverseGeocodeCallCount = 0 - calculateRouteCallCount = 0 - searchLocationsCallCount = 0 - config = .default - } - - // MARK: - Simulated Network - - private func simulateNetwork() async throws { - if config.simulatedLatency > 0 { - try await Task.sleep(nanoseconds: UInt64(config.simulatedLatency * 1_000_000_000)) - } - } - - // MARK: - Geocoding - - func geocode(_ address: String) async throws -> CLLocationCoordinate2D? { - geocodeCallCount += 1 - try await simulateNetwork() - - if config.shouldFailGeocode { - throw LocationError.geocodingFailed - } - - // Check pre-configured responses - if let coordinate = geocodeResponses[address.lowercased()] { - return coordinate - } - - // Return nil for unknown addresses (simulating "not found") - return nil - } - - func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> String? { - reverseGeocodeCallCount += 1 - try await simulateNetwork() - - if config.shouldFailGeocode { - throw LocationError.geocodingFailed - } - - // Return a simple formatted string based on coordinates - return "Location at \(String(format: "%.2f", coordinate.latitude)), \(String(format: "%.2f", coordinate.longitude))" - } - - func resolveLocation(_ input: LocationInput) async throws -> LocationInput { - if input.isResolved { return input } - - let searchText = input.address ?? input.name - guard let coordinate = try await geocode(searchText) else { - throw LocationError.geocodingFailed - } - - return LocationInput( - name: input.name, - coordinate: coordinate, - address: input.address - ) - } - - // MARK: - Location Search - - func searchLocations(_ query: String) async throws -> [LocationSearchResult] { - searchLocationsCallCount += 1 - try await simulateNetwork() - - if config.shouldFailGeocode { - return [] - } - - // Check if we have a pre-configured response for this query - if let coordinate = geocodeResponses[query.lowercased()] { - return [ - LocationSearchResult( - name: query, - address: "Mocked Address", - coordinate: coordinate - ) - ] - } - - return [] - } - - // MARK: - Distance Calculations - - func calculateDistance( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> CLLocationDistance { - if config.useHaversineForDistance { - return haversineDistance(from: from, to: to) - } - - // Simple Euclidean approximation (less accurate but faster) - let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude) - let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude) - return fromLocation.distance(from: toLocation) - } - - func calculateDrivingRoute( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) async throws -> RouteInfo { - calculateRouteCallCount += 1 - try await simulateNetwork() - - if config.shouldFailRoute { - throw LocationError.routeNotFound - } - - // Check pre-configured routes - let key = routeKey(from: from, to: to) - if let route = routeResponses[key] { - return route - } - - // Generate estimated route based on haversine distance - let distanceMeters = haversineDistance(from: from, to: to) - let distanceMiles = distanceMeters * 0.000621371 - - // Estimate driving time (add 20% for real-world conditions) - let drivingHours = (distanceMiles / config.defaultDrivingSpeedMPH) * 1.2 - let travelTimeSeconds = drivingHours * 3600 - - return RouteInfo( - distance: distanceMeters, - expectedTravelTime: travelTimeSeconds, - polyline: nil - ) - } - - func calculateDrivingMatrix( - origins: [CLLocationCoordinate2D], - destinations: [CLLocationCoordinate2D] - ) async throws -> [[RouteInfo?]] { - var matrix: [[RouteInfo?]] = [] - - for origin in origins { - var row: [RouteInfo?] = [] - for destination in destinations { - do { - let route = try await calculateDrivingRoute(from: origin, to: destination) - row.append(route) - } catch { - row.append(nil) - } - } - matrix.append(row) - } - - return matrix - } - - // MARK: - Haversine Distance - - /// Calculate haversine distance between two coordinates in meters - private func haversineDistance( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> CLLocationDistance { - let earthRadiusMeters: Double = 6371000.0 - - let lat1 = from.latitude * .pi / 180 - let lat2 = to.latitude * .pi / 180 - let deltaLat = (to.latitude - from.latitude) * .pi / 180 - let deltaLon = (to.longitude - from.longitude) * .pi / 180 - - let a = sin(deltaLat / 2) * sin(deltaLat / 2) + - cos(lat1) * cos(lat2) * - sin(deltaLon / 2) * sin(deltaLon / 2) - let c = 2 * atan2(sqrt(a), sqrt(1 - a)) - - return earthRadiusMeters * c - } - - // MARK: - Helpers - - private func routeKey(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> String { - "\(from.latitude),\(from.longitude)->\(to.latitude),\(to.longitude)" - } -} - -// MARK: - Convenience Extensions - -extension MockLocationService { - /// Pre-configure common city geocoding responses - func loadCommonCities() async { - await setGeocodeResponse(for: "New York, NY", coordinate: FixtureGenerator.KnownLocations.nyc) - await setGeocodeResponse(for: "Los Angeles, CA", coordinate: FixtureGenerator.KnownLocations.la) - await setGeocodeResponse(for: "Chicago, IL", coordinate: FixtureGenerator.KnownLocations.chicago) - await setGeocodeResponse(for: "Boston, MA", coordinate: FixtureGenerator.KnownLocations.boston) - await setGeocodeResponse(for: "Miami, FL", coordinate: FixtureGenerator.KnownLocations.miami) - await setGeocodeResponse(for: "Seattle, WA", coordinate: FixtureGenerator.KnownLocations.seattle) - await setGeocodeResponse(for: "Denver, CO", coordinate: FixtureGenerator.KnownLocations.denver) - } - - /// Create a mock service with common cities pre-loaded - static func withCommonCities() async -> MockLocationService { - let mock = MockLocationService() - await mock.loadCommonCities() - return mock - } -} - -// MARK: - Test Helpers - -extension MockLocationService { - /// Calculate expected travel time in hours for a given distance - func expectedTravelHours(distanceMiles: Double) -> Double { - (distanceMiles / config.defaultDrivingSpeedMPH) * 1.2 - } - - /// Check if a coordinate is within radius of another - func isWithinRadius( - _ coordinate: CLLocationCoordinate2D, - of center: CLLocationCoordinate2D, - radiusMiles: Double - ) -> Bool { - let distanceMeters = haversineDistance(from: center, to: coordinate) - let distanceMiles = distanceMeters * 0.000621371 - return distanceMiles <= radiusMiles - } -} diff --git a/SportsTimeTests/Planning/ConcurrencyTests.swift b/SportsTimeTests/Planning/ConcurrencyTests.swift deleted file mode 100644 index d6c9475..0000000 --- a/SportsTimeTests/Planning/ConcurrencyTests.swift +++ /dev/null @@ -1,241 +0,0 @@ -// -// ConcurrencyTests.swift -// SportsTimeTests -// -// Phase 10: Concurrency Tests -// Documents current thread-safety behavior for future refactoring reference. -// - -import Testing -import CoreLocation -@testable import SportsTime - -@Suite("Concurrency Tests", .serialized) -struct ConcurrencyTests { - - // MARK: - Test Fixtures - - private let calendar = Calendar.current - - /// Creates a date with specific year/month/day/hour - private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date { - var components = DateComponents() - components.year = year - components.month = month - components.day = day - components.hour = hour - components.minute = 0 - return calendar.date(from: components)! - } - - /// Creates a stadium at a known location - private func makeStadium( - id: String = "stadium_test_\(UUID().uuidString)", - city: String, - lat: Double, - lon: Double, - sport: Sport = .mlb - ) -> Stadium { - Stadium( - id: id, - name: "\(city) Stadium", - city: city, - state: "ST", - latitude: lat, - longitude: lon, - capacity: 40000, - sport: sport - ) - } - - /// Creates a game at a stadium - private func makeGame( - id: String = "game_test_\(UUID().uuidString)", - stadiumId: String, - homeTeamId: String = "team_test_\(UUID().uuidString)", - awayTeamId: String = "team_test_\(UUID().uuidString)", - dateTime: Date, - sport: Sport = .mlb - ) -> Game { - Game( - id: id, - homeTeamId: homeTeamId, - awayTeamId: awayTeamId, - stadiumId: stadiumId, - dateTime: dateTime, - sport: sport, - season: "2026" - ) - } - - /// Creates a PlanningRequest for Scenario A (date range only) - private func makeScenarioARequest( - startDate: Date, - endDate: Date, - games: [Game], - stadiums: [String: Stadium] - ) -> PlanningRequest { - let preferences = TripPreferences( - planningMode: .dateRange, - sports: [.mlb], - startDate: startDate, - endDate: endDate, - leisureLevel: .moderate, - numberOfDrivers: 1, - maxDrivingHoursPerDriver: 8.0, - allowRepeatCities: true - ) - - return PlanningRequest( - preferences: preferences, - availableGames: games, - teams: [:], - stadiums: stadiums - ) - } - - /// Creates a valid test request with nearby cities - private func makeValidTestRequest(requestIndex: Int) -> PlanningRequest { - // Use different but nearby city pairs for each request to create variety - let cityPairs: [(city1: (String, Double, Double), city2: (String, Double, Double))] = [ - (("Chicago", 41.8781, -87.6298), ("Milwaukee", 43.0389, -87.9065)), - (("New York", 40.7128, -73.9352), ("Philadelphia", 39.9526, -75.1652)), - (("Boston", 42.3601, -71.0589), ("Providence", 41.8240, -71.4128)), - (("Los Angeles", 34.0522, -118.2437), ("San Diego", 32.7157, -117.1611)), - (("Seattle", 47.6062, -122.3321), ("Portland", 45.5152, -122.6784)), - ] - - let pair = cityPairs[requestIndex % cityPairs.count] - - let stadium1Id = "stadium_1_\(UUID().uuidString)" - let stadium2Id = "stadium_2_\(UUID().uuidString)" - - let stadium1 = makeStadium(id: stadium1Id, city: pair.city1.0, lat: pair.city1.1, lon: pair.city1.2) - let stadium2 = makeStadium(id: stadium2Id, city: pair.city2.0, lat: pair.city2.1, lon: pair.city2.2) - - let stadiums = [stadium1Id: stadium1, stadium2Id: stadium2] - - // Games on different days for feasible routing - let baseDay = 5 + (requestIndex * 2) % 20 - let game1 = makeGame(stadiumId: stadium1Id, dateTime: makeDate(day: baseDay, hour: 19)) - let game2 = makeGame(stadiumId: stadium2Id, dateTime: makeDate(day: baseDay + 2, hour: 19)) - - return makeScenarioARequest( - startDate: makeDate(day: baseDay - 1, hour: 0), - endDate: makeDate(day: baseDay + 5, hour: 23), - games: [game1, game2], - stadiums: stadiums - ) - } - - // MARK: - 10.1: Concurrent Requests Test - - @Test("10.1 - Concurrent requests behavior documentation") - func test_engine_ConcurrentRequests_CurrentlyUnsafe() async { - // DOCUMENTATION TEST - // Purpose: Document the current behavior when TripPlanningEngine is called concurrently. - // - // Current Implementation Status: - // - TripPlanningEngine is a `final class` (not an actor) - // - It appears stateless - no mutable instance state persists between calls - // - Each call to planItineraries creates fresh planner instances - // - // Expected Behavior: - // - If truly stateless: concurrent calls should succeed independently - // - If hidden state exists: may see race conditions or crashes - // - // This test documents the current behavior for future refactoring reference. - - let concurrentRequestCount = 10 - let engine = TripPlanningEngine() - - // Create unique requests for each concurrent task - let requests = (0..