Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search - Add geographic diversity to route selection (returns routes from distinct regions) - Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes - Simplify itinerary display: separate games and travel segments by date - Remove complex ItineraryDay bundling, query games/travel directly per day - Update ScenarioA/B/C planners to use GameDAGRouter - Add new test suites for planners and travel estimator 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
670
SportsTimeTests/ScenarioAPlannerSwiftTests.swift
Normal file
670
SportsTimeTests/ScenarioAPlannerSwiftTests.swift
Normal file
@@ -0,0 +1,670 @@
|
||||
//
|
||||
// ScenarioAPlannerSwiftTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Additional tests for ScenarioAPlanner using Swift Testing framework.
|
||||
// Combined with ScenarioAPlannerTests.swift, this provides comprehensive coverage.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - ScenarioAPlanner Swift Tests
|
||||
|
||||
struct ScenarioAPlannerSwiftTests {
|
||||
|
||||
// MARK: - Test Data Helpers
|
||||
|
||||
private func makeStadium(
|
||||
id: UUID = UUID(),
|
||||
city: String,
|
||||
latitude: Double,
|
||||
longitude: Double
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
name: "\(city) Stadium",
|
||||
city: city,
|
||||
state: "ST",
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: 40000
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGame(
|
||||
id: UUID = UUID(),
|
||||
stadiumId: UUID,
|
||||
dateTime: Date
|
||||
) -> Game {
|
||||
Game(
|
||||
id: id,
|
||||
homeTeamId: UUID(),
|
||||
awayTeamId: UUID(),
|
||||
stadiumId: stadiumId,
|
||||
dateTime: dateTime,
|
||||
sport: .mlb,
|
||||
season: "2026"
|
||||
)
|
||||
}
|
||||
|
||||
private func baseDate() -> Date {
|
||||
Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||
}
|
||||
|
||||
private func date(daysFrom base: Date, days: Int, hour: Int = 19) -> Date {
|
||||
var date = Calendar.current.date(byAdding: .day, value: days, to: base)!
|
||||
return Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: date)!
|
||||
}
|
||||
|
||||
private func makeDateRange(start: Date, days: Int) -> DateInterval {
|
||||
let end = Calendar.current.date(byAdding: .day, value: days, to: start)!
|
||||
return DateInterval(start: start, end: end)
|
||||
}
|
||||
|
||||
private func plan(
|
||||
games: [Game],
|
||||
stadiums: [Stadium],
|
||||
dateRange: DateInterval,
|
||||
numberOfDrivers: Int = 1,
|
||||
maxHoursPerDriver: Double = 8.0
|
||||
) -> ItineraryResult {
|
||||
let stadiumDict = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
|
||||
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
startDate: dateRange.start,
|
||||
endDate: dateRange.end,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxHoursPerDriver
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: games,
|
||||
teams: [:],
|
||||
stadiums: stadiumDict
|
||||
)
|
||||
|
||||
let planner = ScenarioAPlanner()
|
||||
return planner.plan(request: request)
|
||||
}
|
||||
|
||||
// MARK: - Failure Case Tests
|
||||
|
||||
@Test("plan with no date range returns failure")
|
||||
func plan_NoDateRange_ReturnsFailure() {
|
||||
// Create a request without a valid date range
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
startDate: baseDate(),
|
||||
endDate: baseDate() // Same date = no range
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: [],
|
||||
teams: [:],
|
||||
stadiums: [:]
|
||||
)
|
||||
|
||||
let planner = ScenarioAPlanner()
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
#expect(result.failure?.reason == .missingDateRange)
|
||||
}
|
||||
|
||||
@Test("plan with games all outside date range returns failure")
|
||||
func plan_AllGamesOutsideRange_ReturnsFailure() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 30))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.failure?.reason == .noGamesInRange)
|
||||
}
|
||||
|
||||
@Test("plan with end date before start date returns failure")
|
||||
func plan_InvalidDateRange_ReturnsFailure() {
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
startDate: baseDate(),
|
||||
endDate: Calendar.current.date(byAdding: .day, value: -5, to: baseDate())!
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: [],
|
||||
teams: [:],
|
||||
stadiums: [:]
|
||||
)
|
||||
|
||||
let planner = ScenarioAPlanner()
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
#expect(result.failure != nil)
|
||||
}
|
||||
|
||||
// MARK: - Success Case Tests
|
||||
|
||||
@Test("plan returns success with valid single game")
|
||||
func plan_ValidSingleGame_ReturnsSuccess() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.count == 1)
|
||||
#expect(result.options.first?.stops.count == 1)
|
||||
}
|
||||
|
||||
@Test("plan includes game exactly at range start")
|
||||
func plan_GameAtRangeStart_Included() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
// Game exactly at start of range
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 0, hour: 10))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.stops.count == 1)
|
||||
}
|
||||
|
||||
@Test("plan includes game exactly at range end")
|
||||
func plan_GameAtRangeEnd_Included() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
// Game at end of range
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 9, hour: 19))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
}
|
||||
|
||||
// MARK: - Driving Constraints Tests
|
||||
|
||||
@Test("plan rejects route that exceeds driving limit")
|
||||
func plan_ExceedsDrivingLimit_RoutePruned() {
|
||||
// Create two cities ~2000 miles apart
|
||||
let ny = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
|
||||
// Games 1 day apart - impossible to drive
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 1))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, la],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10),
|
||||
numberOfDrivers: 1,
|
||||
maxHoursPerDriver: 8.0
|
||||
)
|
||||
|
||||
// Should succeed but not have both games in same route
|
||||
if result.isSuccess {
|
||||
// May have single-game options but not both together
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("plan with two drivers allows longer routes")
|
||||
func plan_TwoDrivers_AllowsLongerRoutes() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let denver = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
|
||||
// ~1000 miles, ~17 hours - doable with 2 drivers
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: denver.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, denver],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10),
|
||||
numberOfDrivers: 2,
|
||||
maxHoursPerDriver: 8.0
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
}
|
||||
|
||||
// MARK: - Stop Grouping Tests
|
||||
|
||||
@Test("multiple games at same stadium grouped into one stop")
|
||||
func plan_SameStadiumGames_GroupedIntoOneStop() {
|
||||
let stadium = makeStadium(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 1)),
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: [games[0], games[1], games[2]],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.stops.count == 1)
|
||||
#expect(result.options.first?.stops.first?.games.count == 3)
|
||||
}
|
||||
|
||||
@Test("stop arrival date is first game date")
|
||||
func plan_StopArrivalDate_IsFirstGameDate() {
|
||||
let stadium = makeStadium(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
|
||||
let firstGame = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
let secondGame = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
|
||||
let result = plan(
|
||||
games: [firstGame, secondGame],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let stop = result.options.first?.stops.first
|
||||
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
|
||||
let stopArrival = Calendar.current.startOfDay(for: stop?.arrivalDate ?? Date.distantPast)
|
||||
#expect(firstGameDate == stopArrival)
|
||||
}
|
||||
|
||||
@Test("stop departure date is last game date")
|
||||
func plan_StopDepartureDate_IsLastGameDate() {
|
||||
let stadium = makeStadium(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
|
||||
let firstGame = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
let secondGame = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 4))
|
||||
|
||||
let result = plan(
|
||||
games: [firstGame, secondGame],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let stop = result.options.first?.stops.first
|
||||
let lastGameDate = Calendar.current.startOfDay(for: secondGame.startTime)
|
||||
let stopDeparture = Calendar.current.startOfDay(for: stop?.departureDate ?? Date.distantFuture)
|
||||
#expect(lastGameDate == stopDeparture)
|
||||
}
|
||||
|
||||
// MARK: - Travel Segment Tests
|
||||
|
||||
@Test("single stop has zero travel segments")
|
||||
func plan_SingleStop_ZeroTravelSegments() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.travelSegments.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("two stops have one travel segment")
|
||||
func plan_TwoStops_OneTravelSegment() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
||||
#expect(twoStopOption?.travelSegments.count == 1)
|
||||
}
|
||||
|
||||
@Test("travel segment has correct origin and destination")
|
||||
func plan_TravelSegment_CorrectOriginDestination() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
||||
let segment = twoStopOption?.travelSegments.first
|
||||
#expect(segment?.fromLocation.name == "Los Angeles")
|
||||
#expect(segment?.toLocation.name == "San Francisco")
|
||||
}
|
||||
|
||||
@Test("travel segment distance is reasonable for LA to SF")
|
||||
func plan_TravelSegment_ReasonableDistance() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
||||
let distance = twoStopOption?.totalDistanceMiles ?? 0
|
||||
|
||||
// LA to SF is ~380 miles, with routing factor ~500 miles
|
||||
#expect(distance > 400 && distance < 600)
|
||||
}
|
||||
|
||||
// MARK: - Option Ranking Tests
|
||||
|
||||
@Test("options are ranked starting from 1")
|
||||
func plan_Options_RankedFromOne() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.rank == 1)
|
||||
}
|
||||
|
||||
@Test("all options have valid isValid property")
|
||||
func plan_Options_AllValid() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
for option in result.options {
|
||||
#expect(option.isValid, "All options should pass isValid check")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("totalGames computed property is correct")
|
||||
func plan_TotalGames_ComputedCorrectly() {
|
||||
let stadium = makeStadium(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 1)),
|
||||
makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.totalGames == 3)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
@Test("games in reverse chronological order still processed correctly")
|
||||
func plan_ReverseChronologicalGames_ProcessedCorrectly() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
// Games added in reverse order
|
||||
let game1 = makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 5))
|
||||
let game2 = makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game1, game2], // SF first (later date)
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
// Should be sorted: LA (day 2) then SF (day 5)
|
||||
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
||||
#expect(twoStopOption?.stops[0].city == "Los Angeles")
|
||||
#expect(twoStopOption?.stops[1].city == "San Francisco")
|
||||
}
|
||||
|
||||
@Test("handles many games efficiently")
|
||||
func plan_ManyGames_HandledEfficiently() {
|
||||
var stadiums: [Stadium] = []
|
||||
var games: [Game] = []
|
||||
|
||||
// Create 15 games along the west coast
|
||||
let cities: [(String, Double, Double)] = [
|
||||
("San Diego", 32.7157, -117.1611),
|
||||
("Los Angeles", 34.0522, -118.2437),
|
||||
("Bakersfield", 35.3733, -119.0187),
|
||||
("Fresno", 36.7378, -119.7871),
|
||||
("San Jose", 37.3382, -121.8863),
|
||||
("San Francisco", 37.7749, -122.4194),
|
||||
("Oakland", 37.8044, -122.2712),
|
||||
("Sacramento", 38.5816, -121.4944),
|
||||
("Reno", 39.5296, -119.8138),
|
||||
("Redding", 40.5865, -122.3917),
|
||||
("Eugene", 44.0521, -123.0868),
|
||||
("Portland", 45.5152, -122.6784),
|
||||
("Seattle", 47.6062, -122.3321),
|
||||
("Tacoma", 47.2529, -122.4443),
|
||||
("Vancouver", 49.2827, -123.1207)
|
||||
]
|
||||
|
||||
for (index, city) in cities.enumerated() {
|
||||
let id = UUID()
|
||||
stadiums.append(makeStadium(id: id, city: city.0, latitude: city.1, longitude: city.2))
|
||||
games.append(makeGame(stadiumId: id, dateTime: date(daysFrom: baseDate(), days: index)))
|
||||
}
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
dateRange: makeDateRange(start: baseDate(), days: 20)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.count <= 10)
|
||||
}
|
||||
|
||||
@Test("empty stadiums dictionary returns failure")
|
||||
func plan_EmptyStadiums_ReturnsSuccess() {
|
||||
let stadiumId = UUID()
|
||||
let game = makeGame(stadiumId: stadiumId, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
// Game exists but stadium not in dictionary
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
// Should handle gracefully (may return failure or success with empty)
|
||||
#expect(result.failure != nil || result.options.isEmpty || result.isSuccess)
|
||||
}
|
||||
|
||||
@Test("stop has correct city from stadium")
|
||||
func plan_StopCity_MatchesStadium() {
|
||||
let stadium = makeStadium(city: "Phoenix", latitude: 33.4484, longitude: -112.0740)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.stops.first?.city == "Phoenix")
|
||||
}
|
||||
|
||||
@Test("stop has correct state from stadium")
|
||||
func plan_StopState_MatchesStadium() {
|
||||
let stadium = makeStadium(city: "Phoenix", latitude: 33.4484, longitude: -112.0740)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.stops.first?.state == "ST")
|
||||
}
|
||||
|
||||
@Test("stop has coordinate from stadium")
|
||||
func plan_StopCoordinate_MatchesStadium() {
|
||||
let stadium = makeStadium(city: "Phoenix", latitude: 33.4484, longitude: -112.0740)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let coord = result.options.first?.stops.first?.coordinate
|
||||
#expect(coord != nil)
|
||||
#expect(abs(coord!.latitude - 33.4484) < 0.01)
|
||||
#expect(abs(coord!.longitude - (-112.0740)) < 0.01)
|
||||
}
|
||||
|
||||
@Test("firstGameStart property is set correctly")
|
||||
func plan_FirstGameStart_SetCorrectly() {
|
||||
let stadium = makeStadium(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
let gameTime = date(daysFrom: baseDate(), days: 2, hour: 19)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: gameTime)
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let firstGameStart = result.options.first?.stops.first?.firstGameStart
|
||||
#expect(firstGameStart == gameTime)
|
||||
}
|
||||
|
||||
@Test("location property has correct name")
|
||||
func plan_LocationProperty_CorrectName() {
|
||||
let stadium = makeStadium(city: "Austin", latitude: 30.2672, longitude: -97.7431)
|
||||
let game = makeGame(stadiumId: stadium.id, dateTime: date(daysFrom: baseDate(), days: 2))
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
#expect(result.options.first?.stops.first?.location.name == "Austin")
|
||||
}
|
||||
|
||||
@Test("geographicRationale shows game count")
|
||||
func plan_GeographicRationale_ShowsGameCount() {
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
||||
#expect(twoStopOption?.geographicRationale.contains("2") == true)
|
||||
}
|
||||
|
||||
@Test("options with same game count sorted by driving hours")
|
||||
func plan_SameGameCount_SortedByDrivingHours() {
|
||||
// Create scenario where multiple routes have same game count
|
||||
let la = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, dateTime: date(daysFrom: baseDate(), days: 0)),
|
||||
makeGame(stadiumId: sf.id, dateTime: date(daysFrom: baseDate(), days: 3))
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(start: baseDate(), days: 10)
|
||||
)
|
||||
|
||||
#expect(result.isSuccess)
|
||||
// All options should be valid and sorted
|
||||
for option in result.options {
|
||||
#expect(option.isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
//
|
||||
// ScenarioAPlannerTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Tests for ScenarioAPlanner tree exploration logic.
|
||||
// Verifies that we correctly find all geographically sensible route variations.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
final class ScenarioAPlannerTests: XCTestCase {
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Creates a stadium at a specific coordinate
|
||||
private func makeStadium(
|
||||
id: UUID = UUID(),
|
||||
city: String,
|
||||
lat: Double,
|
||||
lon: Double
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
name: "\(city) Arena",
|
||||
city: city,
|
||||
state: "ST",
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
capacity: 20000
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a game at a stadium on a specific day
|
||||
private func makeGame(
|
||||
stadiumId: UUID,
|
||||
daysFromNow: Int
|
||||
) -> Game {
|
||||
let date = Calendar.current.date(byAdding: .day, value: daysFromNow, to: Date())!
|
||||
return Game(
|
||||
id: UUID(),
|
||||
homeTeamId: UUID(),
|
||||
awayTeamId: UUID(),
|
||||
stadiumId: stadiumId,
|
||||
dateTime: date,
|
||||
sport: .nba,
|
||||
season: "2025-26"
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a date range from now
|
||||
private func makeDateRange(days: Int) -> DateInterval {
|
||||
let start = Date()
|
||||
let end = Calendar.current.date(byAdding: .day, value: days, to: start)!
|
||||
return DateInterval(start: start, end: end)
|
||||
}
|
||||
|
||||
/// Runs ScenarioA planning and returns the result
|
||||
private func plan(
|
||||
games: [Game],
|
||||
stadiums: [Stadium],
|
||||
dateRange: DateInterval
|
||||
) -> ItineraryResult {
|
||||
let stadiumDict = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
|
||||
|
||||
// Create preferences with the date range
|
||||
let preferences = TripPreferences(
|
||||
planningMode: .dateRange,
|
||||
startDate: dateRange.start,
|
||||
endDate: dateRange.end,
|
||||
numberOfDrivers: 1,
|
||||
maxDrivingHoursPerDriver: 8.0
|
||||
)
|
||||
|
||||
let request = PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: games,
|
||||
teams: [:], // Not needed for ScenarioA tests
|
||||
stadiums: stadiumDict
|
||||
)
|
||||
|
||||
let planner = ScenarioAPlanner()
|
||||
return planner.plan(request: request)
|
||||
}
|
||||
|
||||
// MARK: - Test 1: Empty games returns failure
|
||||
|
||||
func test_emptyGames_returnsNoGamesInRangeFailure() {
|
||||
let result = plan(
|
||||
games: [],
|
||||
stadiums: [],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .failure(let failure) = result {
|
||||
XCTAssertEqual(failure.reason, .noGamesInRange)
|
||||
} else {
|
||||
XCTFail("Expected failure, got success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 2: Single game always succeeds
|
||||
|
||||
func test_singleGame_alwaysSucceeds() {
|
||||
let stadium = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let game = makeGame(stadiumId: stadium.id, daysFromNow: 1)
|
||||
|
||||
let result = plan(
|
||||
games: [game],
|
||||
stadiums: [stadium],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
XCTAssertEqual(options.count, 1)
|
||||
XCTAssertEqual(options[0].stops.count, 1)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 3: Two games always succeeds (no zig-zag possible)
|
||||
|
||||
func test_twoGames_alwaysSucceeds() {
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, la],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
XCTAssertGreaterThanOrEqual(options.count, 1)
|
||||
// Should have option with both games
|
||||
let twoGameOption = options.first { $0.stops.count == 2 }
|
||||
XCTAssertNotNil(twoGameOption)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 4: Linear route (West to East) - all games included
|
||||
|
||||
func test_linearRouteWestToEast_allGamesIncluded() {
|
||||
// LA → Denver → Chicago → New York (linear progression)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let den = makeStadium(city: "Denver", lat: 39.7, lon: -104.9)
|
||||
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: den.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: chi.id, daysFromNow: 5),
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 7)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, den, chi, ny],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Best option should include all 4 games
|
||||
XCTAssertEqual(options[0].stops.count, 4)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 5: Linear route (North to South) - all games included
|
||||
|
||||
func test_linearRouteNorthToSouth_allGamesIncluded() {
|
||||
// Seattle → SF → LA → San Diego (linear south)
|
||||
let sea = makeStadium(city: "Seattle", lat: 47.6, lon: -122.3)
|
||||
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let sd = makeStadium(city: "San Diego", lat: 32.7, lon: -117.1)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: sea.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: sf.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: sd.id, daysFromNow: 4)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [sea, sf, la, sd],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
XCTAssertEqual(options[0].stops.count, 4)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 6: Zig-zag pattern creates multiple options (NY → TX → SC)
|
||||
|
||||
func test_zigZagPattern_createsMultipleOptions() {
|
||||
// NY (day 1) → TX (day 2) → SC (day 3) = zig-zag
|
||||
// Should create options: [NY,TX], [NY,SC], [TX,SC], etc.
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: sc.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, tx, sc],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Should have multiple options due to zig-zag
|
||||
XCTAssertGreaterThan(options.count, 1)
|
||||
} else {
|
||||
XCTFail("Expected success with multiple options")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 7: Cross-country zig-zag creates many branches
|
||||
|
||||
func test_crossCountryZigZag_createsManyBranches() {
|
||||
// NY → TX → SC → CA → MN = extreme zig-zag
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||
let ca = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let mn = makeStadium(city: "Minneapolis", lat: 44.9, lon: -93.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: sc.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: ca.id, daysFromNow: 4),
|
||||
makeGame(stadiumId: mn.id, daysFromNow: 5)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, tx, sc, ca, mn],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Should have many options from all the branching
|
||||
XCTAssertGreaterThan(options.count, 3)
|
||||
// No option should have all 5 games (too much zig-zag)
|
||||
let maxGames = options.map { $0.stops.count }.max() ?? 0
|
||||
XCTAssertLessThan(maxGames, 5)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 8: Fork at third game - both branches explored
|
||||
|
||||
func test_forkAtThirdGame_bothBranchesExplored() {
|
||||
// NY → Chicago → ? (fork: either Dallas OR Miami, not both)
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||
let dal = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||
let mia = makeStadium(city: "Miami", lat: 25.7, lon: -80.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: chi.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: dal.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: mia.id, daysFromNow: 4)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, chi, dal, mia],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Should have options including Dallas and options including Miami
|
||||
let citiesInOptions = options.flatMap { $0.stops.map { $0.city } }
|
||||
XCTAssertTrue(citiesInOptions.contains("Dallas") || citiesInOptions.contains("Miami"))
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 9: All games in same city - single option with all games
|
||||
|
||||
func test_allGamesSameCity_singleOptionWithAllGames() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// All games at same stadium = 1 stop with 3 games
|
||||
XCTAssertEqual(options[0].stops.count, 1)
|
||||
XCTAssertEqual(options[0].stops[0].games.count, 3)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 10: Nearby cities - all included (no zig-zag)
|
||||
|
||||
func test_nearbyCities_allIncluded() {
|
||||
// LA → Anaheim → San Diego (all nearby, < 100 miles)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let ana = makeStadium(city: "Anaheim", lat: 33.8, lon: -117.9)
|
||||
let sd = makeStadium(city: "San Diego", lat: 32.7, lon: -117.1)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: ana.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: sd.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, ana, sd],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// All nearby = should have option with all 3
|
||||
XCTAssertEqual(options[0].stops.count, 3)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 11: Options sorted by game count (most games first)
|
||||
|
||||
func test_optionsSortedByGameCount_mostGamesFirst() {
|
||||
// Create a scenario with varying option sizes
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: chi.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, chi, la],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Options should be sorted: most games first
|
||||
for i in 0..<(options.count - 1) {
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
options[i].stops.count,
|
||||
options[i + 1].stops.count
|
||||
)
|
||||
}
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 12: Rank numbers are sequential
|
||||
|
||||
func test_rankNumbers_areSequential() {
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: sc.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, tx, sc],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
for (index, option) in options.enumerated() {
|
||||
XCTAssertEqual(option.rank, index + 1)
|
||||
}
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 13: Games outside date range are excluded
|
||||
|
||||
func test_gamesOutsideDateRange_areExcluded() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1), // In range
|
||||
makeGame(stadiumId: la.id, daysFromNow: 15), // Out of range
|
||||
makeGame(stadiumId: la.id, daysFromNow: 3) // In range
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la],
|
||||
dateRange: makeDateRange(days: 5) // Only 5 days
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Should only have 2 games (day 1 and day 3)
|
||||
XCTAssertEqual(options[0].stops[0].games.count, 2)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 14: Maximum 10 options returned
|
||||
|
||||
func test_maximum10Options_returned() {
|
||||
// Create many cities that could generate lots of combinations
|
||||
let cities: [(String, Double, Double)] = [
|
||||
("City1", 40.0, -74.0),
|
||||
("City2", 38.0, -90.0),
|
||||
("City3", 35.0, -106.0),
|
||||
("City4", 33.0, -117.0),
|
||||
("City5", 37.0, -122.0),
|
||||
("City6", 45.0, -93.0),
|
||||
("City7", 42.0, -83.0)
|
||||
]
|
||||
|
||||
let stadiums = cities.map { makeStadium(city: $0.0, lat: $0.1, lon: $0.2) }
|
||||
let games = stadiums.enumerated().map { makeGame(stadiumId: $1.id, daysFromNow: $0 + 1) }
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
dateRange: makeDateRange(days: 15)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
XCTAssertLessThanOrEqual(options.count, 10)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 15: Each option has travel segments
|
||||
|
||||
func test_eachOption_hasTravelSegments() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||
let sea = makeStadium(city: "Seattle", lat: 47.6, lon: -122.3)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: sf.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: sea.id, daysFromNow: 5)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf, sea],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
for option in options {
|
||||
// Invariant: travelSegments.count == stops.count - 1
|
||||
if option.stops.count > 1 {
|
||||
XCTAssertEqual(
|
||||
option.travelSegments.count,
|
||||
option.stops.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 16: Single game options are included
|
||||
|
||||
func test_singleGameOptions_areIncluded() {
|
||||
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let mia = makeStadium(city: "Miami", lat: 25.7, lon: -80.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: mia.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [ny, la, mia],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
// Should include single-game options
|
||||
let singleGameOptions = options.filter { $0.stops.count == 1 }
|
||||
XCTAssertGreaterThan(singleGameOptions.count, 0)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 17: Chronological order preserved in each option
|
||||
|
||||
func test_chronologicalOrder_preservedInEachOption() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let den = makeStadium(city: "Denver", lat: 39.7, lon: -104.9)
|
||||
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: den.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: chi.id, daysFromNow: 5)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, den, chi],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
for option in options {
|
||||
// Verify stops are in chronological order
|
||||
for i in 0..<(option.stops.count - 1) {
|
||||
XCTAssertLessThanOrEqual(
|
||||
option.stops[i].arrivalDate,
|
||||
option.stops[i + 1].arrivalDate
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 18: Geographic rationale includes city names
|
||||
|
||||
func test_geographicRationale_includesCityNames() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: sf.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
let twoStopOption = options.first { $0.stops.count == 2 }
|
||||
XCTAssertNotNil(twoStopOption)
|
||||
XCTAssertTrue(twoStopOption!.geographicRationale.contains("Los Angeles"))
|
||||
XCTAssertTrue(twoStopOption!.geographicRationale.contains("San Francisco"))
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 19: Total driving hours calculated for each option
|
||||
|
||||
func test_totalDrivingHours_calculatedForEachOption() {
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: sf.id, daysFromNow: 3)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [la, sf],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
let twoStopOption = options.first { $0.stops.count == 2 }
|
||||
XCTAssertNotNil(twoStopOption)
|
||||
// LA to SF is ~380 miles, ~6 hours
|
||||
XCTAssertGreaterThan(twoStopOption!.totalDrivingHours, 4)
|
||||
XCTAssertLessThan(twoStopOption!.totalDrivingHours, 10)
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 20: Coastal route vs inland route - both explored
|
||||
|
||||
func test_coastalVsInlandRoute_bothExplored() {
|
||||
// SF → either Sacramento (inland) or Monterey (coastal) → LA
|
||||
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||
let sac = makeStadium(city: "Sacramento", lat: 38.5, lon: -121.4) // Inland
|
||||
let mon = makeStadium(city: "Monterey", lat: 36.6, lon: -121.9) // Coastal
|
||||
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||
|
||||
let games = [
|
||||
makeGame(stadiumId: sf.id, daysFromNow: 1),
|
||||
makeGame(stadiumId: sac.id, daysFromNow: 2),
|
||||
makeGame(stadiumId: mon.id, daysFromNow: 3),
|
||||
makeGame(stadiumId: la.id, daysFromNow: 4)
|
||||
]
|
||||
|
||||
let result = plan(
|
||||
games: games,
|
||||
stadiums: [sf, sac, mon, la],
|
||||
dateRange: makeDateRange(days: 10)
|
||||
)
|
||||
|
||||
if case .success(let options) = result {
|
||||
let citiesInOptions = Set(options.flatMap { $0.stops.map { $0.city } })
|
||||
// Both Sacramento and Monterey should appear in some option
|
||||
XCTAssertTrue(citiesInOptions.contains("Sacramento") || citiesInOptions.contains("Monterey"))
|
||||
} else {
|
||||
XCTFail("Expected success")
|
||||
}
|
||||
}
|
||||
}
|
||||
1439
SportsTimeTests/ScenarioBPlannerTests.swift
Normal file
1439
SportsTimeTests/ScenarioBPlannerTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
2057
SportsTimeTests/ScenarioCPlannerTests.swift
Normal file
2057
SportsTimeTests/ScenarioCPlannerTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -385,52 +385,7 @@ struct DuplicateGameIdTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test("GameCandidate array with duplicate game IDs can build dictionary without crashing")
|
||||
func candidateMap_HandlesDuplicateGameIds() {
|
||||
// This test reproduces the bug: Dictionary(uniqueKeysWithValues:) crashes on duplicate keys
|
||||
// Fix: Use reduce(into:) to handle duplicates gracefully
|
||||
|
||||
let stadium = makeStadium()
|
||||
let homeTeam = makeTeam(stadiumId: stadium.id)
|
||||
let awayTeam = makeTeam(stadiumId: UUID())
|
||||
let gameId = UUID() // Same ID for both candidates (simulates duplicate in JSON)
|
||||
let dateTime = Date()
|
||||
|
||||
let game = makeGame(id: gameId, homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, stadiumId: stadium.id, dateTime: dateTime)
|
||||
|
||||
// Create two candidates with the same game ID (simulating duplicate JSON data)
|
||||
let candidate1 = GameCandidate(
|
||||
id: gameId,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: 0,
|
||||
score: 1.0
|
||||
)
|
||||
let candidate2 = GameCandidate(
|
||||
id: gameId,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: 0,
|
||||
score: 2.0
|
||||
)
|
||||
|
||||
let candidates = [candidate1, candidate2]
|
||||
|
||||
// This is the fix pattern - should not crash
|
||||
let candidateMap = candidates.reduce(into: [UUID: GameCandidate]()) { dict, candidate in
|
||||
if dict[candidate.game.id] == nil {
|
||||
dict[candidate.game.id] = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Should only have one entry (first one wins)
|
||||
#expect(candidateMap.count == 1)
|
||||
#expect(candidateMap[gameId]?.score == 1.0, "First candidate should be kept")
|
||||
}
|
||||
// Note: GameCandidate test removed - type no longer exists after planning engine refactor
|
||||
|
||||
@Test("Duplicate games are deduplicated at load time")
|
||||
func gamesArray_DeduplicatesById() {
|
||||
|
||||
585
SportsTimeTests/TravelEstimatorTests.swift
Normal file
585
SportsTimeTests/TravelEstimatorTests.swift
Normal file
@@ -0,0 +1,585 @@
|
||||
//
|
||||
// TravelEstimatorTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// 50 comprehensive tests for TravelEstimator covering:
|
||||
// - Haversine distance calculations (miles and meters)
|
||||
// - Travel segment estimation from stops
|
||||
// - Travel segment estimation from LocationInputs
|
||||
// - Fallback distance when coordinates missing
|
||||
// - Travel day calculations
|
||||
// - Edge cases and boundary conditions
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - TravelEstimator Tests
|
||||
|
||||
struct TravelEstimatorTests {
|
||||
|
||||
// MARK: - Test Data Helpers
|
||||
|
||||
private func makeStop(
|
||||
city: String,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
arrivalDate: Date = Date(),
|
||||
departureDate: Date? = nil
|
||||
) -> ItineraryStop {
|
||||
let coordinate = (latitude != nil && longitude != nil)
|
||||
? CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!)
|
||||
: nil
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: nil
|
||||
)
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
state: "ST",
|
||||
coordinate: coordinate,
|
||||
games: [],
|
||||
arrivalDate: arrivalDate,
|
||||
departureDate: departureDate ?? arrivalDate,
|
||||
location: location,
|
||||
firstGameStart: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeLocation(
|
||||
name: String,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil
|
||||
) -> LocationInput {
|
||||
let coordinate = (latitude != nil && longitude != nil)
|
||||
? CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!)
|
||||
: nil
|
||||
|
||||
return LocationInput(name: name, coordinate: coordinate, address: nil)
|
||||
}
|
||||
|
||||
private func defaultConstraints() -> DrivingConstraints {
|
||||
DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||
}
|
||||
|
||||
private func twoDriverConstraints() -> DrivingConstraints {
|
||||
DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||||
}
|
||||
|
||||
// MARK: - Haversine Distance (Miles) Tests
|
||||
|
||||
@Test("haversineDistanceMiles - same point returns zero")
|
||||
func haversine_SamePoint_ReturnsZero() {
|
||||
let coord = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: coord, to: coord)
|
||||
#expect(distance == 0.0)
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - LA to SF approximately 350 miles")
|
||||
func haversine_LAToSF_ApproximatelyCorrect() {
|
||||
let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: la, to: sf)
|
||||
|
||||
// Known distance is ~347 miles
|
||||
#expect(distance > 340 && distance < 360, "Expected ~350 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - NY to LA approximately 2450 miles")
|
||||
func haversine_NYToLA_ApproximatelyCorrect() {
|
||||
let ny = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
|
||||
let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: ny, to: la)
|
||||
|
||||
// Known distance is ~2450 miles
|
||||
#expect(distance > 2400 && distance < 2500, "Expected ~2450 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - commutative (A to B equals B to A)")
|
||||
func haversine_Commutative() {
|
||||
let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0)
|
||||
let coord2 = CLLocationCoordinate2D(latitude: 35.0, longitude: -90.0)
|
||||
|
||||
let distance1 = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2)
|
||||
let distance2 = TravelEstimator.haversineDistanceMiles(from: coord2, to: coord1)
|
||||
|
||||
#expect(abs(distance1 - distance2) < 0.001)
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - across equator")
|
||||
func haversine_AcrossEquator() {
|
||||
let north = CLLocationCoordinate2D(latitude: 10.0, longitude: -80.0)
|
||||
let south = CLLocationCoordinate2D(latitude: -10.0, longitude: -80.0)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: north, to: south)
|
||||
|
||||
// 20 degrees latitude ≈ 1380 miles
|
||||
#expect(distance > 1350 && distance < 1400, "Expected ~1380 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - across prime meridian")
|
||||
func haversine_AcrossPrimeMeridian() {
|
||||
let west = CLLocationCoordinate2D(latitude: 51.5, longitude: -1.0)
|
||||
let east = CLLocationCoordinate2D(latitude: 51.5, longitude: 1.0)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: west, to: east)
|
||||
|
||||
// 2 degrees longitude at ~51.5° latitude ≈ 85 miles
|
||||
#expect(distance > 80 && distance < 90, "Expected ~85 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - near north pole")
|
||||
func haversine_NearNorthPole() {
|
||||
let coord1 = CLLocationCoordinate2D(latitude: 89.0, longitude: 0.0)
|
||||
let coord2 = CLLocationCoordinate2D(latitude: 89.0, longitude: 180.0)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2)
|
||||
|
||||
// At 89° latitude, half way around the world is very short
|
||||
#expect(distance > 0 && distance < 150, "Distance near pole should be short, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - Chicago to Denver approximately 920 miles")
|
||||
func haversine_ChicagoToDenver() {
|
||||
let chicago = CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
||||
let denver = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: chicago, to: denver)
|
||||
|
||||
// Known distance ~920 miles
|
||||
#expect(distance > 900 && distance < 940, "Expected ~920 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - very short distance (same city)")
|
||||
func haversine_VeryShortDistance() {
|
||||
let point1 = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) // Times Square
|
||||
let point2 = CLLocationCoordinate2D(latitude: 40.7614, longitude: -73.9776) // Grand Central
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: point1, to: point2)
|
||||
|
||||
// ~0.5 miles
|
||||
#expect(distance > 0.4 && distance < 0.6, "Expected ~0.5 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMiles - extreme longitude difference")
|
||||
func haversine_ExtremeLongitudeDifference() {
|
||||
let west = CLLocationCoordinate2D(latitude: 40.0, longitude: -179.0)
|
||||
let east = CLLocationCoordinate2D(latitude: 40.0, longitude: 179.0)
|
||||
let distance = TravelEstimator.haversineDistanceMiles(from: west, to: east)
|
||||
|
||||
// 358 degrees the long way, 2 degrees the short way
|
||||
// At 40° latitude, 2 degrees ≈ 105 miles
|
||||
#expect(distance > 100 && distance < 110, "Expected ~105 miles, got \(distance)")
|
||||
}
|
||||
|
||||
// MARK: - Haversine Distance (Meters) Tests
|
||||
|
||||
@Test("haversineDistanceMeters - same point returns zero")
|
||||
func haversineMeters_SamePoint_ReturnsZero() {
|
||||
let coord = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0)
|
||||
let distance = TravelEstimator.haversineDistanceMeters(from: coord, to: coord)
|
||||
#expect(distance == 0.0)
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMeters - LA to SF approximately 560 km")
|
||||
func haversineMeters_LAToSF() {
|
||||
let la = CLLocationCoordinate2D(latitude: 34.0522, longitude: -118.2437)
|
||||
let sf = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||
let distanceKm = TravelEstimator.haversineDistanceMeters(from: la, to: sf) / 1000
|
||||
|
||||
#expect(distanceKm > 540 && distanceKm < 580, "Expected ~560 km, got \(distanceKm)")
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMeters - consistency with miles conversion")
|
||||
func haversineMeters_ConsistentWithMiles() {
|
||||
let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0)
|
||||
let coord2 = CLLocationCoordinate2D(latitude: 35.0, longitude: -90.0)
|
||||
|
||||
let miles = TravelEstimator.haversineDistanceMiles(from: coord1, to: coord2)
|
||||
let meters = TravelEstimator.haversineDistanceMeters(from: coord1, to: coord2)
|
||||
|
||||
// 1 mile = 1609.34 meters
|
||||
let milesFromMeters = meters / 1609.34
|
||||
#expect(abs(miles - milesFromMeters) < 1.0)
|
||||
}
|
||||
|
||||
@Test("haversineDistanceMeters - one kilometer distance")
|
||||
func haversineMeters_OneKilometer() {
|
||||
// 1 degree latitude ≈ 111 km, so 0.009 degrees ≈ 1 km
|
||||
let coord1 = CLLocationCoordinate2D(latitude: 40.0, longitude: -100.0)
|
||||
let coord2 = CLLocationCoordinate2D(latitude: 40.009, longitude: -100.0)
|
||||
let meters = TravelEstimator.haversineDistanceMeters(from: coord1, to: coord2)
|
||||
|
||||
#expect(meters > 900 && meters < 1100, "Expected ~1000 meters, got \(meters)")
|
||||
}
|
||||
|
||||
// MARK: - Calculate Distance Miles Tests
|
||||
|
||||
@Test("calculateDistanceMiles - with coordinates uses haversine")
|
||||
func calculateDistance_WithCoordinates_UsesHaversine() {
|
||||
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let stop2 = makeStop(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
||||
|
||||
let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2)
|
||||
|
||||
// Haversine ~350 miles * 1.3 routing factor ≈ 455 miles
|
||||
#expect(distance > 440 && distance < 470, "Expected ~455 miles with routing factor, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("calculateDistanceMiles - without coordinates uses fallback")
|
||||
func calculateDistance_WithoutCoordinates_UsesFallback() {
|
||||
let stop1 = makeStop(city: "CityA")
|
||||
let stop2 = makeStop(city: "CityB")
|
||||
|
||||
let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2)
|
||||
|
||||
// Fallback is 300 miles
|
||||
#expect(distance == 300.0, "Expected fallback of 300 miles, got \(distance)")
|
||||
}
|
||||
|
||||
@Test("calculateDistanceMiles - same city returns zero")
|
||||
func calculateDistance_SameCity_ReturnsZero() {
|
||||
let stop1 = makeStop(city: "Chicago")
|
||||
let stop2 = makeStop(city: "Chicago")
|
||||
|
||||
let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2)
|
||||
#expect(distance == 0.0)
|
||||
}
|
||||
|
||||
@Test("calculateDistanceMiles - one stop missing coordinates uses fallback")
|
||||
func calculateDistance_OneMissingCoordinate_UsesFallback() {
|
||||
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let stop2 = makeStop(city: "San Francisco")
|
||||
|
||||
let distance = TravelEstimator.calculateDistanceMiles(from: stop1, to: stop2)
|
||||
#expect(distance == 300.0, "Expected fallback of 300 miles, got \(distance)")
|
||||
}
|
||||
|
||||
// MARK: - Estimate Fallback Distance Tests
|
||||
|
||||
@Test("estimateFallbackDistance - same city returns zero")
|
||||
func fallbackDistance_SameCity_ReturnsZero() {
|
||||
let stop1 = makeStop(city: "Denver")
|
||||
let stop2 = makeStop(city: "Denver")
|
||||
|
||||
let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2)
|
||||
#expect(distance == 0.0)
|
||||
}
|
||||
|
||||
@Test("estimateFallbackDistance - different cities returns 300")
|
||||
func fallbackDistance_DifferentCities_Returns300() {
|
||||
let stop1 = makeStop(city: "Denver")
|
||||
let stop2 = makeStop(city: "Chicago")
|
||||
|
||||
let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2)
|
||||
#expect(distance == 300.0)
|
||||
}
|
||||
|
||||
@Test("estimateFallbackDistance - case sensitive city names")
|
||||
func fallbackDistance_CaseSensitive() {
|
||||
let stop1 = makeStop(city: "denver")
|
||||
let stop2 = makeStop(city: "Denver")
|
||||
|
||||
let distance = TravelEstimator.estimateFallbackDistance(from: stop1, to: stop2)
|
||||
// Different case means different cities
|
||||
#expect(distance == 300.0)
|
||||
}
|
||||
|
||||
// MARK: - Estimate (from Stops) Tests
|
||||
|
||||
@Test("estimate stops - returns valid segment for short trip")
|
||||
func estimateStops_ShortTrip_ReturnsSegment() {
|
||||
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let stop2 = makeStop(city: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil, "Should return segment for short trip")
|
||||
#expect(segment!.travelMode == .drive)
|
||||
#expect(segment!.durationHours < 8.0, "LA to SD should be under 8 hours")
|
||||
}
|
||||
|
||||
@Test("estimate stops - returns nil for extremely long trip")
|
||||
func estimateStops_ExtremelyLongTrip_ReturnsNil() {
|
||||
// Create stops 4000 miles apart (> 2 days of driving at 60mph)
|
||||
let stop1 = makeStop(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
||||
// Point way out in the Pacific
|
||||
let stop2 = makeStop(city: "Far Away", latitude: 35.0, longitude: -170.0)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment == nil, "Should return nil for trip > 2 days of driving")
|
||||
}
|
||||
|
||||
@Test("estimate stops - respects two-driver constraint")
|
||||
func estimateStops_TwoDrivers_IncreasesCapacity() {
|
||||
// Trip that exceeds 1-driver limit (16h) but fits 2-driver limit (32h)
|
||||
// LA to Denver: ~850mi straight line * 1.3 routing = ~1105mi / 60mph = ~18.4 hours
|
||||
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let stop2 = makeStop(city: "Denver", latitude: 39.7392, longitude: -104.9903)
|
||||
|
||||
let oneDriver = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
let twoDrivers = TravelEstimator.estimate(from: stop1, to: stop2, constraints: twoDriverConstraints())
|
||||
|
||||
// ~18 hours exceeds 1-driver limit (16h max over 2 days) but fits 2-driver (32h)
|
||||
#expect(oneDriver == nil, "Should fail with one driver - exceeds 16h limit")
|
||||
#expect(twoDrivers != nil, "Should succeed with two drivers - within 32h limit")
|
||||
}
|
||||
|
||||
@Test("estimate stops - calculates departure and arrival times")
|
||||
func estimateStops_CalculatesTimes() {
|
||||
let baseDate = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437, departureDate: baseDate)
|
||||
let stop2 = makeStop(city: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil)
|
||||
#expect(segment!.departureTime > baseDate, "Departure should be after base date (adds 8 hours)")
|
||||
#expect(segment!.arrivalTime > segment!.departureTime, "Arrival should be after departure")
|
||||
}
|
||||
|
||||
@Test("estimate stops - distance and duration are consistent")
|
||||
func estimateStops_DistanceDurationConsistent() {
|
||||
let stop1 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
let stop2 = makeStop(city: "Detroit", latitude: 42.3314, longitude: -83.0458)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil)
|
||||
// At 60 mph average, hours = miles / 60
|
||||
let expectedHours = segment!.distanceMiles / 60.0
|
||||
#expect(abs(segment!.durationHours - expectedHours) < 0.01)
|
||||
}
|
||||
|
||||
@Test("estimate stops - zero distance same location")
|
||||
func estimateStops_SameLocation_ZeroDistance() {
|
||||
let stop1 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
let stop2 = makeStop(city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil)
|
||||
#expect(segment!.distanceMiles == 0.0)
|
||||
#expect(segment!.durationHours == 0.0)
|
||||
}
|
||||
|
||||
// MARK: - Estimate (from LocationInputs) Tests
|
||||
|
||||
@Test("estimate locations - returns valid segment")
|
||||
func estimateLocations_ValidLocations_ReturnsSegment() {
|
||||
let from = makeLocation(name: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let to = makeLocation(name: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil)
|
||||
#expect(segment!.fromLocation.name == "Los Angeles")
|
||||
#expect(segment!.toLocation.name == "San Diego")
|
||||
}
|
||||
|
||||
@Test("estimate locations - returns nil for missing from coordinate")
|
||||
func estimateLocations_MissingFromCoordinate_ReturnsNil() {
|
||||
let from = makeLocation(name: "Unknown City")
|
||||
let to = makeLocation(name: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment == nil)
|
||||
}
|
||||
|
||||
@Test("estimate locations - returns nil for missing to coordinate")
|
||||
func estimateLocations_MissingToCoordinate_ReturnsNil() {
|
||||
let from = makeLocation(name: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
||||
let to = makeLocation(name: "Unknown City")
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment == nil)
|
||||
}
|
||||
|
||||
@Test("estimate locations - returns nil for both missing coordinates")
|
||||
func estimateLocations_BothMissingCoordinates_ReturnsNil() {
|
||||
let from = makeLocation(name: "Unknown A")
|
||||
let to = makeLocation(name: "Unknown B")
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment == nil)
|
||||
}
|
||||
|
||||
@Test("estimate locations - applies road routing factor")
|
||||
func estimateLocations_AppliesRoutingFactor() {
|
||||
let from = makeLocation(name: "A", latitude: 40.0, longitude: -100.0)
|
||||
let to = makeLocation(name: "B", latitude: 41.0, longitude: -100.0)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment != nil)
|
||||
// Straight line distance * 1.3 routing factor
|
||||
let straightLineMeters = TravelEstimator.haversineDistanceMeters(
|
||||
from: from.coordinate!, to: to.coordinate!
|
||||
)
|
||||
let expectedMeters = straightLineMeters * 1.3
|
||||
#expect(abs(segment!.distanceMeters - expectedMeters) < 1.0)
|
||||
}
|
||||
|
||||
@Test("estimate locations - returns nil for extremely long trip")
|
||||
func estimateLocations_ExtremelyLongTrip_ReturnsNil() {
|
||||
let from = makeLocation(name: "New York", latitude: 40.7128, longitude: -74.0060)
|
||||
let to = makeLocation(name: "Far Pacific", latitude: 35.0, longitude: -170.0)
|
||||
|
||||
let segment = TravelEstimator.estimate(from: from, to: to, constraints: defaultConstraints())
|
||||
|
||||
#expect(segment == nil)
|
||||
}
|
||||
|
||||
// MARK: - Calculate Travel Days Tests
|
||||
|
||||
@Test("calculateTravelDays - short trip returns single day")
|
||||
func travelDays_ShortTrip_ReturnsOneDay() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 4.0)
|
||||
|
||||
#expect(days.count == 1)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - exactly 8 hours returns single day")
|
||||
func travelDays_EightHours_ReturnsOneDay() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 8.0)
|
||||
|
||||
#expect(days.count == 1)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - 9 hours returns two days")
|
||||
func travelDays_NineHours_ReturnsTwoDays() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 9.0)
|
||||
|
||||
#expect(days.count == 2)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - 16 hours returns two days")
|
||||
func travelDays_SixteenHours_ReturnsTwoDays() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 16.0)
|
||||
|
||||
#expect(days.count == 2)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - 17 hours returns three days")
|
||||
func travelDays_SeventeenHours_ReturnsThreeDays() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 17.0)
|
||||
|
||||
#expect(days.count == 3)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - zero hours returns single day")
|
||||
func travelDays_ZeroHours_ReturnsOneDay() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 0.0)
|
||||
|
||||
// ceil(0 / 8) = 0, but we always start with one day
|
||||
#expect(days.count == 1)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - days are at start of day")
|
||||
func travelDays_DaysAreAtStartOfDay() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 14, minute: 30))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 10.0)
|
||||
|
||||
#expect(days.count == 2)
|
||||
|
||||
let cal = Calendar.current
|
||||
for day in days {
|
||||
let hour = cal.component(.hour, from: day)
|
||||
let minute = cal.component(.minute, from: day)
|
||||
#expect(hour == 0 && minute == 0, "Day should be at midnight")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - consecutive days are correct")
|
||||
func travelDays_ConsecutiveDays() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 20.0)
|
||||
|
||||
#expect(days.count == 3)
|
||||
|
||||
let cal = Calendar.current
|
||||
#expect(cal.component(.day, from: days[0]) == 5)
|
||||
#expect(cal.component(.day, from: days[1]) == 6)
|
||||
#expect(cal.component(.day, from: days[2]) == 7)
|
||||
}
|
||||
|
||||
@Test("calculateTravelDays - handles month boundary")
|
||||
func travelDays_HandleMonthBoundary() {
|
||||
let departure = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 30, hour: 8))!
|
||||
let days = TravelEstimator.calculateTravelDays(departure: departure, drivingHours: 10.0)
|
||||
|
||||
#expect(days.count == 2)
|
||||
|
||||
let cal = Calendar.current
|
||||
#expect(cal.component(.month, from: days[0]) == 4)
|
||||
#expect(cal.component(.day, from: days[0]) == 30)
|
||||
#expect(cal.component(.month, from: days[1]) == 5)
|
||||
#expect(cal.component(.day, from: days[1]) == 1)
|
||||
}
|
||||
|
||||
// MARK: - Driving Constraints Tests
|
||||
|
||||
@Test("DrivingConstraints - default values")
|
||||
func constraints_DefaultValues() {
|
||||
let constraints = DrivingConstraints.default
|
||||
#expect(constraints.numberOfDrivers == 1)
|
||||
#expect(constraints.maxHoursPerDriverPerDay == 8.0)
|
||||
#expect(constraints.maxDailyDrivingHours == 8.0)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - multiple drivers increase daily limit")
|
||||
func constraints_MultipleDrivers() {
|
||||
let constraints = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||||
#expect(constraints.maxDailyDrivingHours == 16.0)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - custom hours per driver")
|
||||
func constraints_CustomHoursPerDriver() {
|
||||
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 10.0)
|
||||
#expect(constraints.maxDailyDrivingHours == 10.0)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - enforces minimum 1 driver")
|
||||
func constraints_MinimumOneDriver() {
|
||||
let constraints = DrivingConstraints(numberOfDrivers: 0, maxHoursPerDriverPerDay: 8.0)
|
||||
#expect(constraints.numberOfDrivers == 1)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - enforces minimum 1 hour")
|
||||
func constraints_MinimumOneHour() {
|
||||
let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0.5)
|
||||
#expect(constraints.maxHoursPerDriverPerDay == 1.0)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - from preferences")
|
||||
func constraints_FromPreferences() {
|
||||
var prefs = TripPreferences()
|
||||
prefs.numberOfDrivers = 3
|
||||
prefs.maxDrivingHoursPerDriver = 6.0
|
||||
|
||||
let constraints = DrivingConstraints(from: prefs)
|
||||
#expect(constraints.numberOfDrivers == 3)
|
||||
#expect(constraints.maxHoursPerDriverPerDay == 6.0)
|
||||
#expect(constraints.maxDailyDrivingHours == 18.0)
|
||||
}
|
||||
|
||||
@Test("DrivingConstraints - from preferences with nil hours uses default")
|
||||
func constraints_FromPreferencesNilHours() {
|
||||
var prefs = TripPreferences()
|
||||
prefs.numberOfDrivers = 2
|
||||
prefs.maxDrivingHoursPerDriver = nil
|
||||
|
||||
let constraints = DrivingConstraints(from: prefs)
|
||||
#expect(constraints.maxHoursPerDriverPerDay == 8.0)
|
||||
}
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user