Files
Sportstime/SportsTime/Planning/Models/PlanningModels.swift
Trey t 8162b4a029 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>
2026-01-16 14:07:41 -06:00

572 lines
18 KiB
Swift

//
// PlanningModels.swift
// SportsTime
//
// Clean model types for trip planning.
//
import Foundation
import CoreLocation
// MARK: - Planning Scenario
/// 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
case scenarioC // Start + end locations
case scenarioD // Follow team schedule
}
// MARK: - Planning Failure
/// Explicit failure with reason. No silent failures.
struct PlanningFailure: Error {
let reason: FailureReason
let violations: [ConstraintViolation]
enum FailureReason: Equatable {
case noGamesInRange
case noValidRoutes
case missingDateRange
case missingLocations
case missingTeamSelection
case dateRangeViolation(games: [Game])
case drivingExceedsLimit
case cannotArriveInTime
case travelSegmentMissing
case constraintsUnsatisfiable
case geographicBacktracking
case repeatCityViolation(cities: [String])
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
switch (lhs, rhs) {
case (.noGamesInRange, .noGamesInRange),
(.noValidRoutes, .noValidRoutes),
(.missingDateRange, .missingDateRange),
(.missingLocations, .missingLocations),
(.missingTeamSelection, .missingTeamSelection),
(.drivingExceedsLimit, .drivingExceedsLimit),
(.cannotArriveInTime, .cannotArriveInTime),
(.travelSegmentMissing, .travelSegmentMissing),
(.constraintsUnsatisfiable, .constraintsUnsatisfiable),
(.geographicBacktracking, .geographicBacktracking):
return true
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
return g1.map { $0.id } == g2.map { $0.id }
case (.repeatCityViolation(let c1), .repeatCityViolation(let c2)):
return c1 == c2
default:
return false
}
}
}
init(reason: FailureReason, violations: [ConstraintViolation] = []) {
self.reason = reason
self.violations = violations
}
var message: String {
switch reason {
case .noGamesInRange: return "No games found within the date range"
case .noValidRoutes: return "No valid routes could be constructed"
case .missingDateRange: return "Date range is required"
case .missingLocations: return "Start and end locations are required"
case .missingTeamSelection: return "Select a team to follow"
case .dateRangeViolation(let games):
return "\(games.count) selected game(s) fall outside the date range"
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
case .cannotArriveInTime: return "Cannot arrive before game starts"
case .travelSegmentMissing: return "Travel segment could not be created"
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
case .geographicBacktracking: return "Route requires excessive backtracking"
case .repeatCityViolation(let cities):
let cityList = cities.prefix(3).joined(separator: ", ")
let suffix = cities.count > 3 ? " and \(cities.count - 3) more" : ""
return "Cannot visit cities on multiple days: \(cityList)\(suffix)"
}
}
}
// MARK: - Constraint Violation
struct ConstraintViolation: Equatable {
let type: ConstraintType
let description: String
let severity: ViolationSeverity
init(constraint: String, detail: String) {
self.type = .general
self.description = "\(constraint): \(detail)"
self.severity = .error
}
init(type: ConstraintType, description: String, severity: ViolationSeverity) {
self.type = type
self.description = description
self.severity = severity
}
}
enum ConstraintType: String, Equatable {
case dateRange
case drivingTime
case geographicSanity
case mustStop
case selectedGames
case gameReachability
case general
}
enum ViolationSeverity: Equatable {
case warning
case error
}
// MARK: - Must Stop Config
struct MustStopConfig {
static let defaultProximityMiles: Double = 25
let proximityMiles: Double
init(proximityMiles: Double = MustStopConfig.defaultProximityMiles) {
self.proximityMiles = proximityMiles
}
}
// MARK: - Itinerary Result
/// Either success with ranked options, or explicit failure.
enum ItineraryResult {
case success([ItineraryOption])
case failure(PlanningFailure)
var isSuccess: Bool {
if case .success = self { return true }
return false
}
var options: [ItineraryOption] {
if case .success(let opts) = self { return opts }
return []
}
var failure: PlanningFailure? {
if case .failure(let f) = self { return f }
return nil
}
}
// MARK: - Route Candidate
/// Intermediate structure during planning.
struct RouteCandidate {
let stops: [ItineraryStop]
let rationale: String
}
// MARK: - Itinerary Option
/// A valid, ranked itinerary option.
struct ItineraryOption: Identifiable {
let id = UUID()
let rank: Int
let stops: [ItineraryStop]
let travelSegments: [TravelSegment]
let totalDrivingHours: Double
let totalDistanceMiles: Double
let geographicRationale: String
/// INVARIANT: travelSegments.count == stops.count - 1 (or 0 if single stop)
var isValid: Bool {
if stops.count <= 1 { return travelSegments.isEmpty }
return travelSegments.count == stops.count - 1
}
var totalGames: Int {
stops.reduce(0) { $0 + $1.games.count }
}
/// Sorts and ranks itinerary options based on leisure level preference.
///
/// - Parameters:
/// - options: The itinerary options to sort
/// - leisureLevel: The user's leisure preference
/// - Returns: Sorted and ranked options (all options, no limit)
///
/// - 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
) -> [ItineraryOption] {
let sorted = options.sorted { a, b in
let aGames = a.totalGames
let bGames = b.totalGames
switch leisureLevel {
case .packed:
// Most games first, then least driving
if aGames != bGames { return aGames > bGames }
return a.totalDrivingHours < b.totalDrivingHours
case .moderate:
// Best efficiency (games per driving hour)
let effA = a.totalDrivingHours > 0 ? Double(aGames) / a.totalDrivingHours : Double(aGames)
let effB = b.totalDrivingHours > 0 ? Double(bGames) / b.totalDrivingHours : Double(bGames)
if effA != effB { return effA > effB }
return aGames > bGames
case .relaxed:
// Least driving first, then fewer games is fine
if a.totalDrivingHours != b.totalDrivingHours {
return a.totalDrivingHours < b.totalDrivingHours
}
return aGames < bGames
}
}
// Re-rank after sorting (no limit - return all options)
return sorted.enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
}
}
// MARK: - Itinerary Stop
/// A stop in the itinerary.
struct ItineraryStop: Identifiable, Hashable {
let id = UUID()
let city: String
let state: String
let coordinate: CLLocationCoordinate2D?
let games: [String] // Canonical game IDs
let arrivalDate: Date
let departureDate: Date
let location: LocationInput
let firstGameStart: Date?
var hasGames: Bool { !games.isEmpty }
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ItineraryStop, rhs: ItineraryStop) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Driving 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
static let `default` = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
var maxDailyDrivingHours: Double {
Double(numberOfDrivers) * maxHoursPerDriverPerDay
}
init(numberOfDrivers: Int = 1, maxHoursPerDriverPerDay: Double = 8.0) {
self.numberOfDrivers = max(1, numberOfDrivers)
self.maxHoursPerDriverPerDay = max(1.0, maxHoursPerDriverPerDay)
}
init(from preferences: TripPreferences) {
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
}
}
// MARK: - Timeline Item
/// Unified timeline item for displaying trip itinerary.
/// Enables: Stop Travel Stop Rest Travel Stop pattern
enum TimelineItem: Identifiable {
case stop(ItineraryStop)
case travel(TravelSegment)
case rest(RestDay)
var id: UUID {
switch self {
case .stop(let stop): return stop.id
case .travel(let segment): return segment.id
case .rest(let rest): return rest.id
}
}
var date: Date? {
switch self {
case .stop(let stop): return stop.arrivalDate
case .travel: return nil // Travel is location-based, not date-based
case .rest(let rest): return rest.date
}
}
var isStop: Bool {
if case .stop = self { return true }
return false
}
var isTravel: Bool {
if case .travel = self { return true }
return false
}
var isRest: Bool {
if case .rest = self { return true }
return false
}
/// City/location name for display
var locationName: String {
switch self {
case .stop(let stop): return stop.city
case .travel(let segment): return "\(segment.fromLocation.name)\(segment.toLocation.name)"
case .rest(let rest): return rest.location.name
}
}
}
// MARK: - Rest Day
/// A rest day - staying in one place with no travel or games.
struct RestDay: Identifiable, Hashable {
let id: UUID
let date: Date
let location: LocationInput
let notes: String?
init(
id: UUID = UUID(),
date: Date,
location: LocationInput,
notes: String? = nil
) {
self.id = id
self.date = date
self.location = location
self.notes = notes
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: RestDay, rhs: RestDay) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Timeline Generation
extension ItineraryOption {
/// Generates a unified timeline from stops and travel segments.
///
/// The timeline interleaves stops and travel in chronological order:
/// Stop A Travel AB Stop B Rest Day Travel BC Stop C
///
/// Rest days are inserted when:
/// - There's a gap between arrival at a stop and departure for next travel
/// - Multi-day stays at a location without games
///
func generateTimeline() -> [TimelineItem] {
var timeline: [TimelineItem] = []
let calendar = Calendar.current
for (index, stop) in stops.enumerated() {
// Add the stop
timeline.append(.stop(stop))
// Check for rest days at this stop (days between arrival and departure with no games)
let restDays = calculateRestDays(at: stop, calendar: calendar)
for restDay in restDays {
timeline.append(.rest(restDay))
}
// Add travel segment to next stop (if not last stop)
if index < travelSegments.count {
let segment = travelSegments[index]
// Travel is location-based - just add the segment
// Multi-day travel indicated by durationHours > 8
timeline.append(.travel(segment))
}
}
return timeline
}
/// Calculates rest days at a stop (days with no games).
private func calculateRestDays(
at stop: ItineraryStop,
calendar: Calendar
) -> [RestDay] {
// If stop has no games, the entire stay could be considered rest
// But typically we only insert rest days for multi-day stays
guard stop.hasGames else {
// Start/end locations without games - not rest days, just waypoints
return []
}
var restDays: [RestDay] = []
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
let departureDay = calendar.startOfDay(for: stop.departureDate)
// If multi-day stay, check each day for games
var currentDay = arrivalDay
while currentDay <= departureDay {
// Skip arrival and departure days (those have the stop itself)
if currentDay != arrivalDay && currentDay != departureDay {
// This is a day in between - could be rest or another game day
// For simplicity, mark in-between days as rest
let restDay = RestDay(
date: currentDay,
location: stop.location,
notes: "Rest day in \(stop.city)"
)
restDays.append(restDay)
}
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return restDays
}
/// Timeline organized by date for calendar-style display.
/// Note: Travel segments are excluded as they are location-based, not date-based.
func timelineByDate() -> [Date: [TimelineItem]] {
let calendar = Calendar.current
var byDate: [Date: [TimelineItem]] = [:]
for item in generateTimeline() {
// Skip travel items - they don't have dates
guard let itemDate = item.date else { continue }
let day = calendar.startOfDay(for: itemDate)
byDate[day, default: []].append(item)
}
return byDate
}
/// All dates covered by the itinerary.
func allDates() -> [Date] {
let calendar = Calendar.current
guard let firstStop = stops.first,
let lastStop = stops.last else { return [] }
var dates: [Date] = []
var currentDate = calendar.startOfDay(for: firstStop.arrivalDate)
let endDate = calendar.startOfDay(for: lastStop.departureDate)
while currentDate <= endDate {
dates.append(currentDate)
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
}
return dates
}
}
// MARK: - Planning Request
/// Input to the planning engine.
struct PlanningRequest {
let preferences: TripPreferences
let availableGames: [Game]
let teams: [String: Team] // Keyed by canonical ID
let stadiums: [String: Stadium] // Keyed by canonical ID
// MARK: - Computed Properties for Engine
/// Games the user explicitly selected (anchors for Scenario B)
var selectedGames: [Game] {
availableGames.filter { preferences.mustSeeGameIds.contains($0.id) }
}
/// All available games
var allGames: [Game] {
availableGames
}
/// Start location (for Scenario C)
var startLocation: LocationInput? {
preferences.startLocation
}
/// End location (for Scenario C)
var endLocation: LocationInput? {
preferences.endLocation
}
/// Date range as DateInterval
/// Note: End date is extended to end-of-day to include all games on the last day,
/// since DateInterval.contains() uses exclusive end boundary.
var dateRange: DateInterval? {
guard preferences.endDate > preferences.startDate else { return nil }
// Extend end date to end of day (23:59:59) to include games on the last day
let calendar = Calendar.current
let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate)
?? preferences.endDate
return DateInterval(start: preferences.startDate, end: endOfDay)
}
/// First must-stop location (if any)
var mustStopLocation: LocationInput? {
preferences.mustStopLocations.first
}
/// Driving constraints
var drivingConstraints: DrivingConstraints {
DrivingConstraints(from: preferences)
}
/// Get stadium for a game
func stadium(for game: Game) -> Stadium? {
stadiums[game.stadiumId]
}
}