refactor(tests): TDD rewrite of all unit tests with spec documentation

Complete rewrite of unit test suite using TDD methodology:

Planning Engine Tests:
- GameDAGRouterTests: Beam search, anchor games, transitions
- ItineraryBuilderTests: Stop connection, validators, EV enrichment
- RouteFiltersTests: Region, time window, scoring filters
- ScenarioA/B/C/D PlannerTests: All planning scenarios
- TravelEstimatorTests: Distance, duration, travel days
- TripPlanningEngineTests: Orchestration, caching, preferences

Domain Model Tests:
- AchievementDefinitionsTests, AnySportTests, DivisionTests
- GameTests, ProgressTests, RegionTests, StadiumTests
- TeamTests, TravelSegmentTests, TripTests, TripPollTests
- TripPreferencesTests, TripStopTests, SportTests

Service Tests:
- FreeScoreAPITests, RouteDescriptionGeneratorTests
- SuggestedTripsGeneratorTests

Export Tests:
- ShareableContentTests (card types, themes, dimensions)

Bug fixes discovered through TDD:
- ShareCardDimensions: mapSnapshotSize exceeded available width (960x480)
- ScenarioBPlanner: Added anchor game validation filter

All tests include:
- Specification tests (expected behavior)
- Invariant tests (properties that must always hold)
- Edge case tests (boundary conditions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-16 14:07:41 -06:00
parent 035dd6f5de
commit 8162b4a029
102 changed files with 13409 additions and 9883 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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