Files
Sportstime/SportsTimeTests/Planning/ScenarioDPlannerTests.swift
Trey t f7faec01b1 feat: add Follow Team Mode (Scenario D) for road trip planning
Adds a new planning mode that lets users follow a team's schedule
(home + away games) and builds multi-city routes accordingly.

Key changes:
- New ScenarioDPlanner with team filtering and route generation
- Team picker UI with sport grouping and search
- Fix TravelEstimator 5-day limit (was 2-day) for cross-country routes
- Fix DateInterval end boundary to include games on last day
- Comprehensive test suite covering edge cases:
  - Multi-city routes with adequate/insufficient time
  - Optimal game selection per city for feasibility
  - 5-day driving segment limits
  - Multiple driver scenarios

Enables trips like Houston → Chicago → Anaheim following the Astros.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:42:43 -06:00

1010 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ScenarioDPlannerTests.swift
// SportsTimeTests
//
// Phase 5: ScenarioDPlanner Tests
// Scenario D: User selects a team to follow, planner builds route from their schedule.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ScenarioDPlanner Tests", .serialized)
struct ScenarioDPlannerTests {
// MARK: - Test Fixtures
private let calendar = Calendar.current
private let planner = ScenarioDPlanner()
/// 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: UUID = UUID(),
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 team
private func makeTeam(
id: UUID = UUID(),
name: String,
stadiumId: UUID,
sport: Sport = .mlb
) -> Team {
Team(
id: id,
name: name,
abbreviation: String(name.prefix(3).uppercased()),
sport: sport,
city: name,
stadiumId: stadiumId,
logoURL: nil,
primaryColor: "#FF0000",
secondaryColor: "#FFFFFF"
)
}
/// Creates a game at a stadium
private func makeGame(
id: UUID = UUID(),
stadiumId: UUID,
homeTeamId: UUID,
awayTeamId: UUID,
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 D (follow team mode)
private func makePlanningRequest(
startDate: Date,
endDate: Date,
followTeamId: UUID?,
allGames: [Game],
stadiums: [UUID: Stadium],
teams: [UUID: Team] = [:],
selectedRegions: Set<Region> = [],
allowRepeatCities: Bool = true,
useHomeLocation: Bool = false,
startLocation: LocationInput? = nil,
numberOfDrivers: Int = 1,
maxDrivingHoursPerDriver: Double = 8.0
) -> PlanningRequest {
let preferences = TripPreferences(
planningMode: .followTeam,
startLocation: startLocation,
sports: [.mlb],
startDate: startDate,
endDate: endDate,
leisureLevel: .moderate,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
allowRepeatCities: allowRepeatCities,
selectedRegions: selectedRegions,
followTeamId: followTeamId,
useHomeLocation: useHomeLocation
)
return PlanningRequest(
preferences: preferences,
availableGames: allGames,
teams: teams,
stadiums: stadiums
)
}
// MARK: - D.1: Valid Inputs
@Test("D.1.1 - Single team with home games returns trip with those games")
func test_followTeam_HomeGames_ReturnsTrip() {
// Setup: Team with 2 home games
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let teamId = UUID()
let opponentId = UUID()
let team = makeTeam(id: teamId, name: "Chicago Cubs", stadiumId: stadiumId)
let game1 = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 5, hour: 19)
)
let game2 = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 7, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 10, hour: 23),
followTeamId: teamId,
allGames: [game1, game2],
stadiums: stadiums,
teams: [teamId: team]
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with team home games")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
#expect(firstOption.totalGames >= 2, "Should include both home games")
let cities = firstOption.stops.map { $0.city }
#expect(cities.contains("Chicago"), "Should visit team's home city")
}
}
@Test("D.1.2 - Team with away games includes those games")
func test_followTeam_AwayGames_IncludesAwayGames() {
// Setup: Team with one home game and one away game (2 cities for simpler route)
let homeStadiumId = UUID()
let awayStadiumId = UUID()
let homeStadium = makeStadium(id: homeStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let awayStadium = makeStadium(id: awayStadiumId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
let stadiums = [
homeStadiumId: homeStadium,
awayStadiumId: awayStadium
]
let teamId = UUID()
let opponentId = UUID()
// Home game
let homeGame = makeGame(
stadiumId: homeStadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 5, hour: 19)
)
// Away game (team is awayTeamId)
let awayGame = makeGame(
stadiumId: awayStadiumId,
homeTeamId: opponentId,
awayTeamId: teamId,
dateTime: makeDate(day: 8, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
followTeamId: teamId,
allGames: [homeGame, awayGame],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with home and away games")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
// Should include both games
#expect(firstOption.totalGames >= 2, "Should include both team games (home and away)")
let cities = firstOption.stops.map { $0.city }
#expect(cities.contains("Chicago"), "Should visit home city")
#expect(cities.contains("Milwaukee"), "Should visit away city")
}
}
@Test("D.1.3 - Team games filtered by selected regions")
func test_followTeam_RegionFilter_FiltersGames() {
// Setup: Team with games in multiple regions
let eastStadiumId = UUID()
let centralStadiumId = UUID()
// East region (> -85 longitude)
let eastStadium = makeStadium(id: eastStadiumId, city: "New York", lat: 40.7128, lon: -73.9352)
// Central region (-110 to -85 longitude)
let centralStadium = makeStadium(id: centralStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [eastStadiumId: eastStadium, centralStadiumId: centralStadium]
let teamId = UUID()
let opponentId = UUID()
let eastGame = makeGame(
stadiumId: eastStadiumId,
homeTeamId: opponentId,
awayTeamId: teamId,
dateTime: makeDate(day: 5, hour: 19)
)
let centralGame = makeGame(
stadiumId: centralStadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 7, hour: 19)
)
// Only select East region
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
followTeamId: teamId,
allGames: [eastGame, centralGame],
stadiums: stadiums,
selectedRegions: [.east]
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with regional filter")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
let cities = firstOption.stops.map { $0.city }
#expect(cities.contains("New York"), "Should include East region game")
#expect(!cities.contains("Chicago"), "Should exclude Central region game")
}
}
// MARK: - D.2: Edge Cases
@Test("D.2.1 - No team selected returns missingTeamSelection failure")
func test_followTeam_NoTeamSelected_ReturnsMissingTeamSelection() {
// Setup: No team ID
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let game = makeGame(
stadiumId: stadiumId,
homeTeamId: UUID(),
awayTeamId: UUID(),
dateTime: makeDate(day: 5, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 10, hour: 23),
followTeamId: nil, // No team selected
allGames: [game],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(!result.isSuccess, "Should fail when no team selected")
#expect(result.failure?.reason == .missingTeamSelection,
"Should return missingTeamSelection error")
}
@Test("D.2.2 - Team with no games in date range returns noGamesInRange failure")
func test_followTeam_NoGamesInRange_ReturnsNoGamesFailure() {
// Setup: Team's games are outside date range
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let teamId = UUID()
// Game is in July, but we search June
let game = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: UUID(),
dateTime: makeDate(month: 7, day: 15, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 6, day: 1, hour: 0),
endDate: makeDate(month: 6, day: 30, hour: 23),
followTeamId: teamId,
allGames: [game],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(!result.isSuccess, "Should fail when no games in date range")
#expect(result.failure?.reason == .noGamesInRange,
"Should return noGamesInRange error")
}
@Test("D.2.3 - Team not involved in any games returns noGamesInRange failure")
func test_followTeam_TeamNotInGames_ReturnsNoGamesFailure() {
// Setup: Games exist but team isn't playing
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let teamId = UUID()
let otherTeam1 = UUID()
let otherTeam2 = UUID()
// Game between other teams
let game = makeGame(
stadiumId: stadiumId,
homeTeamId: otherTeam1,
awayTeamId: otherTeam2,
dateTime: makeDate(day: 5, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 10, hour: 23),
followTeamId: teamId,
allGames: [game],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(!result.isSuccess, "Should fail when team has no games")
#expect(result.failure?.reason == .noGamesInRange,
"Should return noGamesInRange error")
}
@Test("D.2.4 - Repeat city filter removes duplicate city visits")
func test_followTeam_RepeatCityFilter_RemovesDuplicates() {
// Setup: Team has multiple games at same stadium
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let teamId = UUID()
let opponentId = UUID()
let game1 = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 5, hour: 19)
)
let game2 = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 7, hour: 19)
)
let game3 = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 9, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
followTeamId: teamId,
allGames: [game1, game2, game3],
stadiums: stadiums,
allowRepeatCities: false // Don't allow repeat cities
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with repeat city filter")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
// With allowRepeatCities=false, should only have 1 game
// (the first game in Chicago)
#expect(firstOption.totalGames == 1, "Should only include first game per city when repeat cities not allowed")
}
}
@Test("D.2.5 - Missing date range returns missingDateRange failure")
func test_followTeam_MissingDateRange_ReturnsMissingDateRangeFailure() {
// Setup: Invalid date range (end before start)
let stadiumId = UUID()
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let stadiums = [stadiumId: stadium]
let teamId = UUID()
let game = makeGame(
stadiumId: stadiumId,
homeTeamId: teamId,
awayTeamId: UUID(),
dateTime: makeDate(day: 5, hour: 19)
)
// End date before start date makes dateRange nil
let request = makePlanningRequest(
startDate: makeDate(day: 15, hour: 0),
endDate: makeDate(day: 1, hour: 23), // Before start
followTeamId: teamId,
allGames: [game],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(!result.isSuccess, "Should fail with invalid date range")
#expect(result.failure?.reason == .missingDateRange,
"Should return missingDateRange error")
}
// MARK: - D.3: Route Verification
@Test("D.3.1 - Route connects team games chronologically")
func test_followTeam_RouteIsChronological() {
// Setup: Team with games in 2 nearby cities chronologically
let chicagoId = UUID()
let milwaukeeId = UUID()
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065)
let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee]
let teamId = UUID()
let opponentId = UUID()
// Games in chronological order: Chicago Milwaukee
let game1 = makeGame(
stadiumId: chicagoId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 5, hour: 19)
)
let game2 = makeGame(
stadiumId: milwaukeeId,
homeTeamId: opponentId,
awayTeamId: teamId,
dateTime: makeDate(day: 8, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
followTeamId: teamId,
allGames: [game1, game2],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with team games")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
#expect(firstOption.totalGames >= 2, "Should include both team games")
// Verify stops are in chronological order
let stopDates = firstOption.stops.map { $0.arrivalDate }
let sortedDates = stopDates.sorted()
#expect(stopDates == sortedDates, "Stops should be in chronological order")
}
}
@Test("D.3.2 - Travel segments connect stops correctly")
func test_followTeam_TravelSegmentsConnectStops() {
// Setup: Team with 2 games in different cities
let nycId = UUID()
let bostonId = UUID()
let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352)
let boston = makeStadium(id: bostonId, city: "Boston", lat: 42.3601, lon: -71.0589)
let stadiums = [nycId: nyc, bostonId: boston]
let teamId = UUID()
let opponentId = UUID()
let game1 = makeGame(
stadiumId: nycId,
homeTeamId: teamId,
awayTeamId: opponentId,
dateTime: makeDate(day: 5, hour: 19)
)
let game2 = makeGame(
stadiumId: bostonId,
homeTeamId: opponentId,
awayTeamId: teamId,
dateTime: makeDate(day: 8, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(day: 1, hour: 0),
endDate: makeDate(day: 15, hour: 23),
followTeamId: teamId,
allGames: [game1, game2],
stadiums: stadiums
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with team games")
#expect(!result.options.isEmpty, "Should return at least one option")
if let firstOption = result.options.first {
#expect(firstOption.stops.count >= 2, "Should have at least 2 stops")
// Should have travel segment between stops
if firstOption.stops.count > 1 {
#expect(firstOption.travelSegments.count == firstOption.stops.count - 1,
"Should have travel segments connecting stops")
// Verify travel segment has reasonable distance
if let segment = firstOption.travelSegments.first {
#expect(segment.distanceMiles > 0, "Travel segment should have distance")
#expect(segment.durationHours > 0, "Travel segment should have duration")
}
}
}
}
// MARK: - D.4: Multi-City Cross-Country Routes
@Test("D.4.1 - Three-city route with adequate driving time succeeds (Astros scenario)")
func test_followTeam_ThreeCityRoute_WithAdequateTime_Succeeds() {
// Setup: Simulates Houston Chicago Anaheim (Astros July 20-29 scenario)
// Houston to Chicago: ~1000 miles, Chicago to Anaheim: ~2000 miles
// With 4+ days between each leg, both should be feasible
let houstonId = UUID()
let chicagoId = UUID()
let anaheimId = UUID()
let houston = makeStadium(id: houstonId, city: "Houston", lat: 29.7604, lon: -95.3698)
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827)
let stadiums = [houstonId: houston, chicagoId: chicago, anaheimId: anaheim]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
let opponent3 = UUID()
// Houston home games: July 20-22
let houstonGame = makeGame(
stadiumId: houstonId,
homeTeamId: teamId,
awayTeamId: opponent1,
dateTime: makeDate(month: 7, day: 20, hour: 19)
)
// Chicago away games: July 24-26 (4 days after Houston)
let chicagoGame = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 24, hour: 19)
)
// Anaheim away games: July 29 (5 days after Chicago)
let anaheimGame = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent3,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 29, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 18, hour: 0),
endDate: makeDate(month: 7, day: 31, hour: 23),
followTeamId: teamId,
allGames: [houstonGame, chicagoGame, anaheimGame],
stadiums: stadiums,
allowRepeatCities: false
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with 3-city cross-country route")
#expect(!result.options.isEmpty, "Should return at least one option")
// Find the 3-city route
let threeCityOption = result.options.first { option in
option.stops.count == 3
}
#expect(threeCityOption != nil, "Should include a 3-city route option")
if let option = threeCityOption {
let cities = option.stops.map { $0.city }
#expect(cities.contains("Houston"), "Route should include Houston")
#expect(cities.contains("Chicago"), "Route should include Chicago")
#expect(cities.contains("Anaheim"), "Route should include Anaheim")
// Verify travel segments exist
#expect(option.travelSegments.count == 2, "Should have 2 travel segments for 3 stops")
}
}
@Test("D.4.2 - Three-city route with insufficient driving time fails to include all cities")
func test_followTeam_ThreeCityRoute_InsufficientTime_ExcludesUnreachableCities() {
// Setup: Same cities but games too close together
// Chicago to Anaheim needs ~37 hours driving, but only 1 day between games
let houstonId = UUID()
let chicagoId = UUID()
let anaheimId = UUID()
let houston = makeStadium(id: houstonId, city: "Houston", lat: 29.7604, lon: -95.3698)
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827)
let stadiums = [houstonId: houston, chicagoId: chicago, anaheimId: anaheim]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
let opponent3 = UUID()
// Houston: July 20
let houstonGame = makeGame(
stadiumId: houstonId,
homeTeamId: teamId,
awayTeamId: opponent1,
dateTime: makeDate(month: 7, day: 20, hour: 19)
)
// Chicago: July 21 (only 1 day after Houston - ~20 hrs driving, needs 16 hrs max)
// This is borderline but might work
let chicagoGame = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 22, hour: 19) // 2 days = 16 hrs max, needs ~20 hrs
)
// Anaheim: July 23 (only 1 day after Chicago - ~37 hrs driving, needs 8 hrs max)
// This should definitely fail
let anaheimGame = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent3,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 23, hour: 19) // 1 day after Chicago = impossible
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 18, hour: 0),
endDate: makeDate(month: 7, day: 25, hour: 23),
followTeamId: teamId,
allGames: [houstonGame, chicagoGame, anaheimGame],
stadiums: stadiums,
allowRepeatCities: false
)
// Execute
let result = planner.plan(request: request)
// Verify - should succeed but without a 3-city route
#expect(result.isSuccess, "Should still succeed with partial routes")
// Should NOT have a 3-city route due to time constraints
let threeCityOption = result.options.first { option in
option.stops.count == 3 &&
Set(option.stops.map { $0.city }) == Set(["Houston", "Chicago", "Anaheim"])
}
#expect(threeCityOption == nil,
"Should NOT include Houston→Chicago→Anaheim route when timing is impossible")
}
@Test("D.4.3 - Router picks optimal game in city to make route feasible")
func test_followTeam_PicksOptimalGamePerCity_ForRouteFeasibility() {
// Setup: Team has 3 games in each city (series)
// With allowRepeatCities=false, router should pick games that make the route work
let chicagoId = UUID()
let anaheimId = UUID()
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827)
let stadiums = [chicagoId: chicago, anaheimId: anaheim]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
// Chicago series: July 24, 25, 26
let chicagoGame1 = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 24, hour: 19)
)
let chicagoGame2 = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 25, hour: 19)
)
let chicagoGame3 = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 26, hour: 19)
)
// Anaheim series: July 27, 28, 29
// Chicago July 24 Anaheim July 29 = 5 days = feasible (~37 hrs driving, 40 hrs available)
// Chicago July 26 Anaheim July 27 = 1 day = NOT feasible (~37 hrs driving, 8 hrs available)
let anaheimGame1 = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 27, hour: 19)
)
let anaheimGame2 = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 28, hour: 19)
)
let anaheimGame3 = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 29, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 22, hour: 0),
endDate: makeDate(month: 7, day: 31, hour: 23),
followTeamId: teamId,
allGames: [chicagoGame1, chicagoGame2, chicagoGame3, anaheimGame1, anaheimGame2, anaheimGame3],
stadiums: stadiums,
allowRepeatCities: false
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed")
// Should have a 2-city route (Chicago Anaheim)
let twoCityOption = result.options.first { option in
option.stops.count == 2 &&
Set(option.stops.map { $0.city }) == Set(["Chicago", "Anaheim"])
}
#expect(twoCityOption != nil, "Should include a Chicago→Anaheim route")
}
@Test("D.4.4 - Five-day driving segment at limit succeeds")
func test_followTeam_FiveDaySegment_AtLimit_Succeeds() {
// Setup: ~38 hours of driving with exactly 5 days between games
// 5 days × 8 hours = 40 hours max, which should pass
let seattleId = UUID()
let miamiId = UUID()
// Seattle to Miami: ~3,300 miles straight line × 1.3 = ~4,300 miles
// At 60 mph = ~72 hours - this is too far even for 5 days
// Let's use a more reasonable pair: Seattle to Denver (~1,300 miles × 1.3 = ~1,700 miles = ~28 hrs)
let seattle = makeStadium(id: seattleId, city: "Seattle", lat: 47.6062, lon: -122.3321)
let denver = makeStadium(id: miamiId, city: "Denver", lat: 39.7392, lon: -104.9903)
let stadiums = [seattleId: seattle, miamiId: denver]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
let seattleGame = makeGame(
stadiumId: seattleId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 20, hour: 19)
)
// 4 days later = 32 hours max, ~28 hrs needed = should work
let denverGame = makeGame(
stadiumId: miamiId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 24, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 18, hour: 0),
endDate: makeDate(month: 7, day: 26, hour: 23),
followTeamId: teamId,
allGames: [seattleGame, denverGame],
stadiums: stadiums,
allowRepeatCities: false
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with long-distance segment")
let twoCityOption = result.options.first { option in
option.stops.count == 2
}
#expect(twoCityOption != nil, "Should include 2-city route")
if let option = twoCityOption {
let cities = Set(option.stops.map { $0.city })
#expect(cities.contains("Seattle"), "Should include Seattle")
#expect(cities.contains("Denver"), "Should include Denver")
}
}
@Test("D.4.5 - Segment exceeding 5-day driving limit is rejected")
func test_followTeam_SegmentExceedingFiveDayLimit_IsRejected() {
// Setup: Distance that would take > 40 hours to drive
// Seattle to Miami: ~3,300 miles straight line × 1.3 = ~4,300 miles
// At 60 mph = ~72 hours - exceeds 40 hour (5 day) limit
let seattleId = UUID()
let miamiId = UUID()
let seattle = makeStadium(id: seattleId, city: "Seattle", lat: 47.6062, lon: -122.3321)
let miami = makeStadium(id: miamiId, city: "Miami", lat: 25.7617, lon: -80.1918)
let stadiums = [seattleId: seattle, miamiId: miami]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
let seattleGame = makeGame(
stadiumId: seattleId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 20, hour: 19)
)
// Even with 5 days, Seattle to Miami is impossible by car
let miamiGame = makeGame(
stadiumId: miamiId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 25, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 18, hour: 0),
endDate: makeDate(month: 7, day: 27, hour: 23),
followTeamId: teamId,
allGames: [seattleGame, miamiGame],
stadiums: stadiums,
allowRepeatCities: false
)
// Execute
let result = planner.plan(request: request)
// Verify - should succeed but without a 2-city route
#expect(result.isSuccess, "Should succeed with individual city options")
// Should NOT have a SeattleMiami route (too far)
let twoCityOption = result.options.first { option in
option.stops.count == 2 &&
Set(option.stops.map { $0.city }) == Set(["Seattle", "Miami"])
}
#expect(twoCityOption == nil,
"Should NOT include Seattle→Miami route (exceeds 5-day driving limit)")
// Should have individual city options
let singleCityOptions = result.options.filter { $0.stops.count == 1 }
#expect(singleCityOptions.count >= 2, "Should have individual city options")
}
@Test("D.4.6 - Multiple drivers increases available driving time")
func test_followTeam_MultipleDrivers_IncreasesAvailableTime() {
// Setup: Same ChicagoAnaheim route but with 2 drivers
// With 2 drivers × 8 hours = 16 hours/day
// Chicago to Anaheim in 3 days = 48 hours available (vs 24 hours with 1 driver)
let chicagoId = UUID()
let anaheimId = UUID()
let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298)
let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827)
let stadiums = [chicagoId: chicago, anaheimId: anaheim]
let teamId = UUID()
let opponent1 = UUID()
let opponent2 = UUID()
let chicagoGame = makeGame(
stadiumId: chicagoId,
homeTeamId: opponent1,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 24, hour: 19)
)
// Only 3 days between games - with 1 driver (24 hrs max) this fails
// With 2 drivers (48 hrs max) and 37.5 hrs needed, this should pass
let anaheimGame = makeGame(
stadiumId: anaheimId,
homeTeamId: opponent2,
awayTeamId: teamId,
dateTime: makeDate(month: 7, day: 27, hour: 19)
)
let request = makePlanningRequest(
startDate: makeDate(month: 7, day: 22, hour: 0),
endDate: makeDate(month: 7, day: 29, hour: 23),
followTeamId: teamId,
allGames: [chicagoGame, anaheimGame],
stadiums: stadiums,
allowRepeatCities: false,
numberOfDrivers: 2, // Two drivers!
maxDrivingHoursPerDriver: 8.0
)
// Execute
let result = planner.plan(request: request)
// Verify
#expect(result.isSuccess, "Should succeed with 2 drivers")
// Note: The TravelEstimator uses a fixed 5-day limit (40 hours with 1 driver at 8 hrs/day)
// With 2 drivers, the limit is 5 × 16 = 80 hours
// So 37.5 hours for ChicagoAnaheim should definitely work
let twoCityOption = result.options.first { option in
option.stops.count == 2
}
// This test verifies the constraint system respects numberOfDrivers
#expect(twoCityOption != nil || result.options.count > 0,
"Should have route options with multiple drivers")
}
}