- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
531 lines
17 KiB
Swift
531 lines
17 KiB
Swift
//
|
|
// TripPlanningEngineTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Fresh test suite for the rewritten trip planning engine.
|
|
// Organized by scenario and validation type.
|
|
//
|
|
|
|
import XCTest
|
|
import CoreLocation
|
|
@testable import SportsTime
|
|
|
|
final class TripPlanningEngineTests: XCTestCase {
|
|
|
|
var engine: TripPlanningEngine!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
engine = TripPlanningEngine()
|
|
}
|
|
|
|
override func tearDown() {
|
|
engine = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
// MARK: - Test Data Helpers
|
|
|
|
func makeGame(
|
|
id: UUID = UUID(),
|
|
dateTime: Date,
|
|
stadiumId: UUID = UUID(),
|
|
homeTeamId: UUID = UUID(),
|
|
awayTeamId: UUID = UUID(),
|
|
sport: Sport = .mlb
|
|
) -> Game {
|
|
Game(
|
|
id: id,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId,
|
|
stadiumId: stadiumId,
|
|
dateTime: dateTime,
|
|
sport: sport,
|
|
season: "2026"
|
|
)
|
|
}
|
|
|
|
func makeStadium(
|
|
id: UUID = UUID(),
|
|
name: String = "Test Stadium",
|
|
city: String = "Test City",
|
|
state: String = "TS",
|
|
latitude: Double = 40.0,
|
|
longitude: Double = -74.0
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: name,
|
|
city: city,
|
|
state: state,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
capacity: 40000
|
|
)
|
|
}
|
|
|
|
func makeTeam(
|
|
id: UUID = UUID(),
|
|
name: String = "Test Team",
|
|
city: String = "Test City",
|
|
stadiumId: UUID = UUID()
|
|
) -> Team {
|
|
Team(
|
|
id: id,
|
|
name: name,
|
|
abbreviation: "TST",
|
|
sport: .mlb,
|
|
city: city,
|
|
stadiumId: stadiumId
|
|
)
|
|
}
|
|
|
|
func makePreferences(
|
|
startDate: Date = Date(),
|
|
endDate: Date = Date().addingTimeInterval(86400 * 7),
|
|
sports: Set<Sport> = [.mlb],
|
|
mustSeeGameIds: Set<UUID> = [],
|
|
startLocation: LocationInput? = nil,
|
|
endLocation: LocationInput? = nil,
|
|
numberOfDrivers: Int = 1,
|
|
maxDrivingHoursPerDriver: Double = 8.0
|
|
) -> TripPreferences {
|
|
TripPreferences(
|
|
planningMode: .dateRange,
|
|
startLocation: startLocation,
|
|
endLocation: endLocation,
|
|
sports: sports,
|
|
mustSeeGameIds: mustSeeGameIds,
|
|
travelMode: .drive,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
numberOfStops: nil,
|
|
tripDuration: nil,
|
|
leisureLevel: .moderate,
|
|
mustStopLocations: [],
|
|
preferredCities: [],
|
|
routePreference: .balanced,
|
|
needsEVCharging: false,
|
|
lodgingType: .hotel,
|
|
numberOfDrivers: numberOfDrivers,
|
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
|
catchOtherSports: false
|
|
)
|
|
}
|
|
|
|
func makeRequest(
|
|
preferences: TripPreferences,
|
|
games: [Game],
|
|
teams: [UUID: Team] = [:],
|
|
stadiums: [UUID: Stadium] = [:]
|
|
) -> PlanningRequest {
|
|
PlanningRequest(
|
|
preferences: preferences,
|
|
availableGames: games,
|
|
teams: teams,
|
|
stadiums: stadiums
|
|
)
|
|
}
|
|
|
|
// MARK: - Scenario A Tests (Date Range)
|
|
|
|
func test_ScenarioA_ValidDateRange_ReturnsItineraries() {
|
|
// Given: A date range with games
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
let stadium = makeStadium(id: stadiumId, city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
let homeTeam = makeTeam(id: homeTeamId, name: "Yankees", city: "New York")
|
|
let awayTeam = makeTeam(id: awayTeamId, name: "Red Sox", city: "Boston")
|
|
|
|
let game = makeGame(
|
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: [game],
|
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
|
stadiums: [stadiumId: stadium]
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertTrue(result.isSuccess, "Should return success for valid date range with games")
|
|
XCTAssertFalse(result.options.isEmpty, "Should return at least one itinerary option")
|
|
}
|
|
|
|
func test_ScenarioA_EmptyDateRange_ReturnsFailure() {
|
|
// Given: An invalid date range (end before start)
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(-86400) // End before start
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(preferences: preferences, games: [])
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isSuccess, "Should fail for invalid date range")
|
|
if case .failure(let failure) = result {
|
|
XCTAssertEqual(failure.reason, .missingDateRange, "Should fail with missingDateRange")
|
|
}
|
|
}
|
|
|
|
func test_ScenarioA_NoGamesInRange_ReturnsFailure() {
|
|
// Given: A valid date range but no games
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(preferences: preferences, games: [])
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isSuccess, "Should fail when no games in range")
|
|
}
|
|
|
|
// MARK: - Scenario B Tests (Selected Games)
|
|
|
|
func test_ScenarioB_SelectedGamesWithinRange_ReturnsSuccess() {
|
|
// Given: Selected games within date range
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let gameId = UUID()
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
|
let homeTeam = makeTeam(id: homeTeamId, name: "Cubs", city: "Chicago")
|
|
let awayTeam = makeTeam(id: awayTeamId, name: "Cardinals", city: "St. Louis")
|
|
|
|
let game = makeGame(
|
|
id: gameId,
|
|
dateTime: startDate.addingTimeInterval(86400 * 3),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
|
|
let preferences = makePreferences(
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
mustSeeGameIds: [gameId]
|
|
)
|
|
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: [game],
|
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
|
stadiums: [stadiumId: stadium]
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertTrue(result.isSuccess, "Should succeed when selected games are within date range")
|
|
}
|
|
|
|
func test_ScenarioB_SelectedGameOutsideDateRange_ReturnsFailure() {
|
|
// Given: A selected game outside the date range
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let gameId = UUID()
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
// Game is 10 days after start, but range is only 7 days
|
|
let game = makeGame(
|
|
id: gameId,
|
|
dateTime: startDate.addingTimeInterval(86400 * 10),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
|
|
let preferences = makePreferences(
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
mustSeeGameIds: [gameId]
|
|
)
|
|
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: [game],
|
|
teams: [:],
|
|
stadiums: [:]
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isSuccess, "Should fail when selected game is outside date range")
|
|
if case .failure(let failure) = result {
|
|
if case .dateRangeViolation(let games) = failure.reason {
|
|
XCTAssertEqual(games.count, 1, "Should report one game out of range")
|
|
XCTAssertEqual(games.first?.id, gameId, "Should report the correct game")
|
|
} else {
|
|
XCTFail("Expected dateRangeViolation failure reason")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scenario C Tests (Start + End Locations)
|
|
|
|
func test_ScenarioC_LinearRoute_ReturnsSuccess() {
|
|
// Given: Start and end locations with games along the way
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let startLocation = LocationInput(
|
|
name: "Chicago",
|
|
coordinate: CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
|
)
|
|
let endLocation = LocationInput(
|
|
name: "New York",
|
|
coordinate: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
|
|
)
|
|
|
|
// Stadium in Cleveland (along the route)
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
let stadium = makeStadium(
|
|
id: stadiumId,
|
|
city: "Cleveland",
|
|
latitude: 41.4993,
|
|
longitude: -81.6944
|
|
)
|
|
|
|
let game = makeGame(
|
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
|
|
let preferences = makePreferences(
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
startLocation: startLocation,
|
|
endLocation: endLocation
|
|
)
|
|
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: [game],
|
|
teams: [homeTeamId: makeTeam(id: homeTeamId), awayTeamId: makeTeam(id: awayTeamId)],
|
|
stadiums: [stadiumId: stadium]
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertTrue(result.isSuccess, "Should succeed for linear route with games")
|
|
}
|
|
|
|
// MARK: - Travel Segment Invariant Tests
|
|
|
|
func test_TravelSegmentCount_EqualsStopsMinusOne() {
|
|
// Given: A multi-stop itinerary
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
var stadiums: [UUID: Stadium] = [:]
|
|
var teams: [UUID: Team] = [:]
|
|
var games: [Game] = []
|
|
|
|
// Create 3 games in 3 cities
|
|
let cities = [
|
|
("New York", 40.7128, -74.0060),
|
|
("Philadelphia", 39.9526, -75.1652),
|
|
("Washington DC", 38.9072, -77.0369)
|
|
]
|
|
|
|
for (index, (city, lat, lon)) in cities.enumerated() {
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
stadiums[stadiumId] = makeStadium(id: stadiumId, city: city, latitude: lat, longitude: lon)
|
|
teams[homeTeamId] = makeTeam(id: homeTeamId, city: city)
|
|
teams[awayTeamId] = makeTeam(id: awayTeamId)
|
|
|
|
let game = makeGame(
|
|
dateTime: startDate.addingTimeInterval(86400 * Double(index + 1)),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
games.append(game)
|
|
}
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: games,
|
|
teams: teams,
|
|
stadiums: stadiums
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
if case .success(let options) = result, let option = options.first {
|
|
let expectedSegments = option.stops.count - 1
|
|
XCTAssertEqual(
|
|
option.travelSegments.count,
|
|
max(0, expectedSegments),
|
|
"Travel segments should equal stops - 1"
|
|
)
|
|
XCTAssertTrue(option.isValid, "Itinerary should pass validity check")
|
|
}
|
|
}
|
|
|
|
func test_SingleStopItinerary_HasZeroTravelSegments() {
|
|
// Given: A single game (single stop)
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
let stadium = makeStadium(id: stadiumId, latitude: 40.7128, longitude: -74.0060)
|
|
let homeTeam = makeTeam(id: homeTeamId)
|
|
let awayTeam = makeTeam(id: awayTeamId)
|
|
|
|
let game = makeGame(
|
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: [game],
|
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
|
stadiums: [stadiumId: stadium]
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
if case .success(let options) = result, let option = options.first {
|
|
if option.stops.count == 1 {
|
|
XCTAssertEqual(option.travelSegments.count, 0, "Single stop should have zero travel segments")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Driving Constraints Tests
|
|
|
|
func test_DrivingConstraints_MultipleDrivers_IncreasesCapacity() {
|
|
// Given: Two drivers instead of one
|
|
let constraints1 = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
|
let constraints2 = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
|
|
|
// Then
|
|
XCTAssertEqual(constraints1.maxDailyDrivingHours, 8.0, "Single driver = 8 hours max")
|
|
XCTAssertEqual(constraints2.maxDailyDrivingHours, 16.0, "Two drivers = 16 hours max")
|
|
}
|
|
|
|
// MARK: - Ranking Tests
|
|
|
|
func test_ItineraryOptions_AreRanked() {
|
|
// Given: Multiple games that could form different routes
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 14)
|
|
|
|
var stadiums: [UUID: Stadium] = [:]
|
|
var teams: [UUID: Team] = [:]
|
|
var games: [Game] = []
|
|
|
|
// Create games with coordinates
|
|
let locations = [
|
|
("City1", 40.0, -74.0),
|
|
("City2", 40.5, -73.5),
|
|
("City3", 41.0, -73.0)
|
|
]
|
|
|
|
for (index, (city, lat, lon)) in locations.enumerated() {
|
|
let stadiumId = UUID()
|
|
let homeTeamId = UUID()
|
|
let awayTeamId = UUID()
|
|
|
|
stadiums[stadiumId] = makeStadium(id: stadiumId, city: city, latitude: lat, longitude: lon)
|
|
teams[homeTeamId] = makeTeam(id: homeTeamId)
|
|
teams[awayTeamId] = makeTeam(id: awayTeamId)
|
|
|
|
let game = makeGame(
|
|
dateTime: startDate.addingTimeInterval(86400 * Double(index + 1)),
|
|
stadiumId: stadiumId,
|
|
homeTeamId: homeTeamId,
|
|
awayTeamId: awayTeamId
|
|
)
|
|
games.append(game)
|
|
}
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(
|
|
preferences: preferences,
|
|
games: games,
|
|
teams: teams,
|
|
stadiums: stadiums
|
|
)
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
if case .success(let options) = result {
|
|
for (index, option) in options.enumerated() {
|
|
XCTAssertEqual(option.rank, index + 1, "Options should be ranked 1, 2, 3, ...")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Edge Case Tests
|
|
|
|
func test_NoGamesAvailable_ReturnsExplicitFailure() {
|
|
// Given: Empty games array
|
|
let startDate = Date()
|
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
|
|
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
|
let request = makeRequest(preferences: preferences, games: [])
|
|
|
|
// When
|
|
let result = engine.planItineraries(request: request)
|
|
|
|
// Then
|
|
XCTAssertFalse(result.isSuccess, "Should return failure for no games")
|
|
XCTAssertNotNil(result.failure, "Should have explicit failure reason")
|
|
}
|
|
}
|