This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1010 lines
38 KiB
Swift
1010 lines
38 KiB
Swift
//
|
||
// 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: String = "stadium_test_\(UUID().uuidString)",
|
||
city: String,
|
||
lat: Double,
|
||
lon: Double,
|
||
sport: Sport = .mlb
|
||
) -> Stadium {
|
||
Stadium(
|
||
id: id,
|
||
name: "\(city) Stadium",
|
||
city: city,
|
||
state: "ST",
|
||
latitude: lat,
|
||
longitude: lon,
|
||
capacity: 40000,
|
||
sport: sport
|
||
)
|
||
}
|
||
|
||
/// Creates a team
|
||
private func makeTeam(
|
||
id: String = "team_test_\(UUID().uuidString)",
|
||
name: String,
|
||
stadiumId: String,
|
||
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: String = "game_test_\(UUID().uuidString)",
|
||
stadiumId: String,
|
||
homeTeamId: String,
|
||
awayTeamId: String,
|
||
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: String?,
|
||
allGames: [Game],
|
||
stadiums: [String: Stadium],
|
||
teams: [String: 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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let teamId = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
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 = "stadium_home_\(UUID().uuidString)"
|
||
let awayStadiumId = "stadium_away_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_east_\(UUID().uuidString)"
|
||
let centralStadiumId = "stadium_central_\(UUID().uuidString)"
|
||
|
||
// 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 = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
|
||
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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let game = makeGame(
|
||
stadiumId: stadiumId,
|
||
homeTeamId: "team_test_\(UUID().uuidString)",
|
||
awayTeamId: "team_opponent_\(UUID().uuidString)",
|
||
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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let teamId = "team_test_\(UUID().uuidString)"
|
||
|
||
// Game is in July, but we search June
|
||
let game = makeGame(
|
||
stadiumId: stadiumId,
|
||
homeTeamId: teamId,
|
||
awayTeamId: "team_opponent_\(UUID().uuidString)",
|
||
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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let teamId = "team_test_\(UUID().uuidString)"
|
||
let otherTeam1 = "team_other1_\(UUID().uuidString)"
|
||
let otherTeam2 = "team_other2_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let teamId = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
|
||
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 = "stadium_test_\(UUID().uuidString)"
|
||
let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298)
|
||
let stadiums = [stadiumId: stadium]
|
||
|
||
let teamId = "team_test_\(UUID().uuidString)"
|
||
|
||
let game = makeGame(
|
||
stadiumId: stadiumId,
|
||
homeTeamId: teamId,
|
||
awayTeamId: "team_opponent_\(UUID().uuidString)",
|
||
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 = "stadium_chicago_\(UUID().uuidString)"
|
||
let milwaukeeId = "stadium_milwaukee_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_nyc_\(UUID().uuidString)"
|
||
let bostonId = "stadium_boston_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponentId = "team_opponent_\(UUID().uuidString)"
|
||
|
||
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 = "stadium_houston_\(UUID().uuidString)"
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let anaheimId = "stadium_anaheim_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
let opponent3 = "team_opponent3_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_houston_\(UUID().uuidString)"
|
||
let chicagoId = "stadium_chicago_\(UUID().uuidString)"
|
||
let anaheimId = "stadium_anaheim_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
let opponent3 = "team_opponent3_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_chicago_\(UUID().uuidString)"
|
||
let anaheimId = "stadium_anaheim_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
|
||
// 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 = "stadium_seattle_\(UUID().uuidString)"
|
||
let miamiId = "stadium_denver_\(UUID().uuidString)"
|
||
|
||
// 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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
|
||
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 = "stadium_seattle_\(UUID().uuidString)"
|
||
let miamiId = "stadium_miami_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
|
||
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 Seattle→Miami 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 Chicago→Anaheim 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 = "stadium_chicago_\(UUID().uuidString)"
|
||
let anaheimId = "stadium_anaheim_\(UUID().uuidString)"
|
||
|
||
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 = "team_test_\(UUID().uuidString)"
|
||
let opponent1 = "team_opponent1_\(UUID().uuidString)"
|
||
let opponent2 = "team_opponent2_\(UUID().uuidString)"
|
||
|
||
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 Chicago→Anaheim 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")
|
||
}
|
||
}
|