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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Date>] = [:]
|
||||
@@ -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<String>()
|
||||
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<Sport>) -> [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<Sport>? = nil,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user