983 lines
37 KiB
Swift
983 lines
37 KiB
Swift
//
|
|
// 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
|
|
|
|
@Suite(.serialized)
|
|
struct ScenarioAPlannerSwiftTests {
|
|
|
|
// MARK: - Test Data Helpers
|
|
|
|
private func makeStadium(
|
|
id: UUID = UUID(),
|
|
city: String,
|
|
latitude: Double,
|
|
longitude: Double,
|
|
sport: Sport = .mlb
|
|
) -> Stadium {
|
|
Stadium(
|
|
id: id,
|
|
name: "\(city) Stadium",
|
|
city: city,
|
|
state: "ST",
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
capacity: 40000,
|
|
sport: sport
|
|
)
|
|
}
|
|
|
|
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
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Timezone Boundary Tests
|
|
|
|
@Test("game at range start in different timezone is included")
|
|
func plan_GameAtRangeStartDifferentTimezone_Included() {
|
|
// Date range: Jan 5 00:00 PST to Jan 10 23:59 PST
|
|
let pstCalendar = Calendar.current
|
|
var pstComponents = DateComponents()
|
|
pstComponents.year = 2026
|
|
pstComponents.month = 1
|
|
pstComponents.day = 5
|
|
pstComponents.hour = 0
|
|
pstComponents.minute = 0
|
|
pstComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeStart = pstCalendar.date(from: pstComponents)!
|
|
|
|
var endComponents = DateComponents()
|
|
endComponents.year = 2026
|
|
endComponents.month = 1
|
|
endComponents.day = 10
|
|
endComponents.hour = 23
|
|
endComponents.minute = 59
|
|
endComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeEnd = pstCalendar.date(from: endComponents)!
|
|
|
|
let dateRange = DateInterval(start: rangeStart, end: rangeEnd)
|
|
|
|
// Game: Jan 5 19:00 EST (New York) = Jan 5 16:00 PST
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
var estComponents = DateComponents()
|
|
estComponents.year = 2026
|
|
estComponents.month = 1
|
|
estComponents.day = 5
|
|
estComponents.hour = 19
|
|
estComponents.minute = 0
|
|
estComponents.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let gameTime = pstCalendar.date(from: estComponents)!
|
|
|
|
let game = makeGame(stadiumId: nyStadium.id, dateTime: gameTime)
|
|
|
|
let result = plan(
|
|
games: [game],
|
|
stadiums: [nyStadium],
|
|
dateRange: dateRange
|
|
)
|
|
|
|
// Game should be included (within PST range)
|
|
#expect(result.isSuccess)
|
|
#expect(result.options.first?.stops.count == 1)
|
|
}
|
|
|
|
@Test("game just before range start in different timezone is excluded")
|
|
func plan_GameBeforeRangeStartDifferentTimezone_Excluded() {
|
|
// Date range: Jan 5 00:00 PST to Jan 10 23:59 PST
|
|
let pstCalendar = Calendar.current
|
|
var pstComponents = DateComponents()
|
|
pstComponents.year = 2026
|
|
pstComponents.month = 1
|
|
pstComponents.day = 5
|
|
pstComponents.hour = 0
|
|
pstComponents.minute = 0
|
|
pstComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeStart = pstCalendar.date(from: pstComponents)!
|
|
|
|
var endComponents = DateComponents()
|
|
endComponents.year = 2026
|
|
endComponents.month = 1
|
|
endComponents.day = 10
|
|
endComponents.hour = 23
|
|
endComponents.minute = 59
|
|
endComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeEnd = pstCalendar.date(from: endComponents)!
|
|
|
|
let dateRange = DateInterval(start: rangeStart, end: rangeEnd)
|
|
|
|
// Game: Jan 4 22:00 EST (New York) = Jan 4 19:00 PST
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
var estComponents = DateComponents()
|
|
estComponents.year = 2026
|
|
estComponents.month = 1
|
|
estComponents.day = 4
|
|
estComponents.hour = 22
|
|
estComponents.minute = 0
|
|
estComponents.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let gameTime = pstCalendar.date(from: estComponents)!
|
|
|
|
let game = makeGame(stadiumId: nyStadium.id, dateTime: gameTime)
|
|
|
|
let result = plan(
|
|
games: [game],
|
|
stadiums: [nyStadium],
|
|
dateRange: dateRange
|
|
)
|
|
|
|
// Game should be excluded (before PST range start)
|
|
#expect(result.failure?.reason == .noGamesInRange)
|
|
}
|
|
|
|
@Test("game at range end in different timezone is included")
|
|
func plan_GameAtRangeEndDifferentTimezone_Included() {
|
|
// Date range: Jan 5 00:00 PST to Jan 10 23:59 PST
|
|
let pstCalendar = Calendar.current
|
|
var pstComponents = DateComponents()
|
|
pstComponents.year = 2026
|
|
pstComponents.month = 1
|
|
pstComponents.day = 5
|
|
pstComponents.hour = 0
|
|
pstComponents.minute = 0
|
|
pstComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeStart = pstCalendar.date(from: pstComponents)!
|
|
|
|
var endComponents = DateComponents()
|
|
endComponents.year = 2026
|
|
endComponents.month = 1
|
|
endComponents.day = 10
|
|
endComponents.hour = 23
|
|
endComponents.minute = 59
|
|
endComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeEnd = pstCalendar.date(from: endComponents)!
|
|
|
|
let dateRange = DateInterval(start: rangeStart, end: rangeEnd)
|
|
|
|
// Game: Jan 10 21:00 EST (New York) = Jan 10 18:00 PST
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
var estComponents = DateComponents()
|
|
estComponents.year = 2026
|
|
estComponents.month = 1
|
|
estComponents.day = 10
|
|
estComponents.hour = 21
|
|
estComponents.minute = 0
|
|
estComponents.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let gameTime = pstCalendar.date(from: estComponents)!
|
|
|
|
let game = makeGame(stadiumId: nyStadium.id, dateTime: gameTime)
|
|
|
|
let result = plan(
|
|
games: [game],
|
|
stadiums: [nyStadium],
|
|
dateRange: dateRange
|
|
)
|
|
|
|
// Game should be included (within PST range)
|
|
#expect(result.isSuccess)
|
|
#expect(result.options.first?.stops.count == 1)
|
|
}
|
|
|
|
@Test("game just after range end in different timezone is excluded")
|
|
func plan_GameAfterRangeEndDifferentTimezone_Excluded() {
|
|
// Date range: Jan 5 00:00 PST to Jan 10 23:59 PST
|
|
let pstCalendar = Calendar.current
|
|
var pstComponents = DateComponents()
|
|
pstComponents.year = 2026
|
|
pstComponents.month = 1
|
|
pstComponents.day = 5
|
|
pstComponents.hour = 0
|
|
pstComponents.minute = 0
|
|
pstComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeStart = pstCalendar.date(from: pstComponents)!
|
|
|
|
var endComponents = DateComponents()
|
|
endComponents.year = 2026
|
|
endComponents.month = 1
|
|
endComponents.day = 10
|
|
endComponents.hour = 23
|
|
endComponents.minute = 59
|
|
endComponents.timeZone = TimeZone(identifier: "America/Los_Angeles")!
|
|
let rangeEnd = pstCalendar.date(from: endComponents)!
|
|
|
|
let dateRange = DateInterval(start: rangeStart, end: rangeEnd)
|
|
|
|
// Game: Jan 11 02:00 EST (New York) = Jan 10 23:00 PST
|
|
// This is actually WITHIN the range (before 23:59 PST)
|
|
// Let me adjust: Jan 11 03:00 EST = Jan 11 00:00 PST (after range)
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
var estComponents = DateComponents()
|
|
estComponents.year = 2026
|
|
estComponents.month = 1
|
|
estComponents.day = 11
|
|
estComponents.hour = 3
|
|
estComponents.minute = 0
|
|
estComponents.timeZone = TimeZone(identifier: "America/New_York")!
|
|
let gameTime = pstCalendar.date(from: estComponents)!
|
|
|
|
let game = makeGame(stadiumId: nyStadium.id, dateTime: gameTime)
|
|
|
|
let result = plan(
|
|
games: [game],
|
|
stadiums: [nyStadium],
|
|
dateRange: dateRange
|
|
)
|
|
|
|
// Game should be excluded (after PST range end)
|
|
#expect(result.failure?.reason == .noGamesInRange)
|
|
}
|
|
|
|
// MARK: - Same-Day Multi-City Conflict Tests
|
|
|
|
@Test("same-day games in close cities are both included in route")
|
|
func plan_SameDayGamesCloseCities_BothIncluded() {
|
|
// LA game at 1pm, San Diego game at 7pm (120 miles, ~2hr drive)
|
|
let laStadium = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
|
let sdStadium = makeStadium(city: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
|
|
|
let base = baseDate()
|
|
let laGame = makeGame(stadiumId: laStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 13))
|
|
let sdGame = makeGame(stadiumId: sdStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 19))
|
|
|
|
let result = plan(
|
|
games: [laGame, sdGame],
|
|
stadiums: [laStadium, sdStadium],
|
|
dateRange: makeDateRange(start: base, days: 10)
|
|
)
|
|
|
|
// Should succeed with both games in route (enough time to drive between)
|
|
#expect(result.isSuccess)
|
|
let twoStopOption = result.options.first { $0.stops.count == 2 }
|
|
#expect(twoStopOption != nil, "Should have route with both cities")
|
|
#expect(twoStopOption?.totalGames == 2)
|
|
}
|
|
|
|
@Test("same-day games in distant cities only one included per route")
|
|
func plan_SameDayGamesDistantCities_OnlyOnePerRoute() {
|
|
// LA game at 1pm, SF game at 7pm (380 miles, ~6hr drive)
|
|
let laStadium = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
|
let sfStadium = makeStadium(city: "San Francisco", latitude: 37.7749, longitude: -122.4194)
|
|
|
|
let base = baseDate()
|
|
let laGame = makeGame(stadiumId: laStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 13))
|
|
let sfGame = makeGame(stadiumId: sfStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 19))
|
|
|
|
let result = plan(
|
|
games: [laGame, sfGame],
|
|
stadiums: [laStadium, sfStadium],
|
|
dateRange: makeDateRange(start: base, days: 10)
|
|
)
|
|
|
|
// Should succeed but each route picks ONE game (cannot attend both same day)
|
|
#expect(result.isSuccess)
|
|
for option in result.options {
|
|
// Each option should have only 1 stop (cannot do both same day)
|
|
#expect(option.stops.count == 1, "Route should pick only one game - cannot attend both LA and SF same day")
|
|
}
|
|
}
|
|
|
|
@Test("same-day games on opposite coasts only one included per route")
|
|
func plan_SameDayGamesOppositCoasts_OnlyOnePerRoute() {
|
|
// LA game at 1pm PST, NY game at 7pm EST (2800 miles, impossible same day)
|
|
let laStadium = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
let base = baseDate()
|
|
let laGame = makeGame(stadiumId: laStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 13))
|
|
let nyGame = makeGame(stadiumId: nyStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 19))
|
|
|
|
let result = plan(
|
|
games: [laGame, nyGame],
|
|
stadiums: [laStadium, nyStadium],
|
|
dateRange: makeDateRange(start: base, days: 10)
|
|
)
|
|
|
|
// Should succeed but each route picks ONE game (obviously impossible same day)
|
|
#expect(result.isSuccess)
|
|
for option in result.options {
|
|
#expect(option.stops.count == 1, "Route should pick only one game - cannot attend both coasts same day")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func plan_ThreeSameDayGames_PicksFeasibleCombinations() {
|
|
// LA 1pm, Anaheim 4pm (30mi), San Diego 7pm (90mi from Anaheim)
|
|
// Feasible: LA→Anaheim→SD
|
|
// Cannot include NY game same day
|
|
let laStadium = makeStadium(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437)
|
|
let anaheimStadium = makeStadium(city: "Anaheim", latitude: 33.8003, longitude: -117.8827)
|
|
let sdStadium = makeStadium(city: "San Diego", latitude: 32.7157, longitude: -117.1611)
|
|
let nyStadium = makeStadium(city: "New York", latitude: 40.7128, longitude: -74.0060)
|
|
|
|
let base = baseDate()
|
|
let laGame = makeGame(stadiumId: laStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 13))
|
|
let anaheimGame = makeGame(stadiumId: anaheimStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 16))
|
|
let sdGame = makeGame(stadiumId: sdStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 19))
|
|
let nyGame = makeGame(stadiumId: nyStadium.id, dateTime: date(daysFrom: base, days: 0, hour: 19))
|
|
|
|
let result = plan(
|
|
games: [laGame, anaheimGame, sdGame, nyGame],
|
|
stadiums: [laStadium, anaheimStadium, sdStadium, nyStadium],
|
|
dateRange: makeDateRange(start: base, days: 10)
|
|
)
|
|
|
|
// Should have options, and best option includes the 3 West Coast games
|
|
#expect(result.isSuccess)
|
|
|
|
// Should have a 3-stop option (LA→Anaheim→SD)
|
|
let threeStopOption = result.options.first { $0.stops.count == 3 }
|
|
#expect(threeStopOption != nil, "Should have route with 3 West Coast stops")
|
|
#expect(threeStopOption?.totalGames == 3)
|
|
|
|
// No option should include NY with any other game from same day
|
|
for option in result.options {
|
|
let cities = option.stops.map { $0.city }
|
|
if cities.contains("New York") {
|
|
#expect(option.stops.count == 1, "NY game cannot be combined with West Coast games same day")
|
|
}
|
|
}
|
|
}
|
|
}
|