Initial commit: SportsTime trip planning app

- Three-scenario planning engine (A: date range, B: selected games, C: directional routes)
- GeographicRouteExplorer with anchor game support for route exploration
- Shared ItineraryBuilder for travel segment calculation
- TravelEstimator for driving time/distance estimation
- SwiftUI views for trip creation and detail display
- CloudKit integration for schedule data
- Python scraping scripts for sports schedules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 00:46:40 -06:00
commit 9088b46563
84 changed files with 180371 additions and 0 deletions

View File

@@ -0,0 +1,655 @@
//
// 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")
}
}
}

View File

@@ -0,0 +1,459 @@
//
// SportsTimeTests.swift
// SportsTimeTests
//
// Created by Trey Tartt on 1/6/26.
//
import Testing
@testable import SportsTime
import Foundation
// MARK: - DayCard Tests
/// Tests for DayCard conflict detection and display logic
struct DayCardTests {
// MARK: - Test Data Helpers
private func makeGame(id: UUID, dateTime: Date, stadiumId: UUID, sport: Sport = .mlb) -> Game {
Game(
id: id,
homeTeamId: UUID(),
awayTeamId: UUID(),
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: "2026"
)
}
private func makeRichGame(game: Game, homeTeamName: String = "Home", awayTeamName: String = "Away") -> RichGame {
let stadiumId = game.stadiumId
let homeTeam = Team(
id: game.homeTeamId,
name: homeTeamName,
abbreviation: "HOM",
sport: game.sport,
city: "Home City",
stadiumId: stadiumId
)
let awayTeam = Team(
id: game.awayTeamId,
name: awayTeamName,
abbreviation: "AWY",
sport: game.sport,
city: "Away City",
stadiumId: UUID()
)
let stadium = Stadium(
id: stadiumId,
name: "Stadium",
city: "City",
state: "ST",
latitude: 40.0,
longitude: -100.0,
capacity: 40000
)
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
private func makeStop(
city: String,
arrivalDate: Date,
departureDate: Date,
games: [UUID]
) -> TripStop {
TripStop(
stopNumber: 1,
city: city,
state: "ST",
arrivalDate: arrivalDate,
departureDate: departureDate,
games: games
)
}
// MARK: - Conflict Detection Tests
@Test("DayCard with specificStop shows only that stop's games")
func dayCard_WithSpecificStop_ShowsOnlyThatStopsGames() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let atlantaGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
let atlantaGameTime = Calendar.current.date(bySettingHour: 23, minute: 15, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let atlantaGame = makeGame(id: atlantaGameId, dateTime: atlantaGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [atlantaGameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
let games: [UUID: RichGame] = [
denverGameId: makeRichGame(game: denverGame),
atlantaGameId: makeRichGame(game: atlantaGame)
]
// Denver card shows only Denver game
let denverCard = DayCard(day: day, games: games, specificStop: denverStop)
#expect(denverCard.gamesOnThisDay.count == 1)
#expect(denverCard.gamesOnThisDay.first?.game.id == denverGameId)
#expect(denverCard.primaryCityForDay == "Denver")
// Atlanta card shows only Atlanta game
let atlantaCard = DayCard(day: day, games: games, specificStop: atlantaStop)
#expect(atlantaCard.gamesOnThisDay.count == 1)
#expect(atlantaCard.gamesOnThisDay.first?.game.id == atlantaGameId)
#expect(atlantaCard.primaryCityForDay == "Atlanta")
}
@Test("DayCard shows conflict warning when conflictInfo provided")
func dayCard_ShowsConflictWarning_WhenConflictInfoProvided() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop],
conflictingCities: ["Denver", "Atlanta"]
)
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
#expect(dayCard.hasConflict == true)
#expect(dayCard.otherConflictingCities == ["Atlanta"])
}
@Test("DayCard without conflict shows no warning")
func dayCard_WithoutConflict_ShowsNoWarning() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.hasConflict == false)
#expect(dayCard.otherConflictingCities.isEmpty)
}
@Test("DayConflictInfo lists all conflicting cities in warning message")
func dayConflictInfo_ListsAllCitiesInWarningMessage() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop, chicagoStop],
conflictingCities: ["Denver", "Atlanta", "Chicago"]
)
#expect(conflictInfo.hasConflict == true)
#expect(conflictInfo.conflictingCities.count == 3)
#expect(conflictInfo.warningMessage.contains("Denver"))
#expect(conflictInfo.warningMessage.contains("Atlanta"))
#expect(conflictInfo.warningMessage.contains("Chicago"))
}
@Test("otherConflictingCities excludes current city")
func otherConflictingCities_ExcludesCurrentCity() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let denverGameId = UUID()
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop, chicagoStop], travelSegments: [])
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
let conflictInfo = DayConflictInfo(
hasConflict: true,
conflictingStops: [denverStop, atlantaStop, chicagoStop],
conflictingCities: ["Denver", "Atlanta", "Chicago"]
)
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
// Should exclude Denver (current city), include Atlanta and Chicago
#expect(dayCard.otherConflictingCities.count == 2)
#expect(dayCard.otherConflictingCities.contains("Atlanta"))
#expect(dayCard.otherConflictingCities.contains("Chicago"))
#expect(!dayCard.otherConflictingCities.contains("Denver"))
}
// MARK: - Basic DayCard Tests
@Test("DayCard handles single stop correctly")
func dayCard_HandlesSingleStop_Correctly() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.gamesOnThisDay.count == 1)
#expect(dayCard.primaryCityForDay == "Chicago")
#expect(dayCard.hasConflict == false)
}
@Test("DayCard handles no stops gracefully")
func dayCard_HandlesNoStops_Gracefully() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [], travelSegments: [])
let dayCard = DayCard(day: day, games: [:])
#expect(dayCard.gamesOnThisDay.isEmpty)
#expect(dayCard.primaryCityForDay == nil)
#expect(dayCard.hasConflict == false)
}
@Test("DayCard handles stop with no games on the specific day")
func dayCard_HandlesStopWithNoGamesOnDay() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let apr6 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 6))!
// Game is on Apr 5, but we're looking at Apr 4
let gameId = UUID()
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
// Stop spans Apr 4-6, but game is on Apr 5
let stop = makeStop(city: "Boston", arrivalDate: apr4, departureDate: apr6, games: [gameId])
// Looking at Apr 4 (arrival day, no game)
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
let dayCard = DayCard(day: day, games: games)
// No games should show on Apr 4 even though the stop has a game (it's on Apr 5)
#expect(dayCard.gamesOnThisDay.isEmpty, "No games on Apr 4, game is on Apr 5")
#expect(dayCard.primaryCityForDay == "Boston", "Still shows the city even without games")
}
@Test("DayCard handles multiple games at same stop on same day (doubleheader)")
func dayCard_HandlesMultipleGamesAtSameStop() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
// Two games in same city on same day (doubleheader)
let game1Id = UUID()
let game2Id = UUID()
let game1Time = Calendar.current.date(bySettingHour: 13, minute: 0, second: 0, of: apr4)!
let game2Time = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
let game1 = makeGame(id: game1Id, dateTime: game1Time, stadiumId: UUID())
let game2 = makeGame(id: game2Id, dateTime: game2Time, stadiumId: UUID())
let stop = makeStop(city: "New York", arrivalDate: apr4, departureDate: apr5, games: [game1Id, game2Id])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
let games: [UUID: RichGame] = [
game1Id: makeRichGame(game: game1),
game2Id: makeRichGame(game: game2)
]
let dayCard = DayCard(day: day, games: games)
#expect(dayCard.gamesOnThisDay.count == 2, "Should show both games from same city")
#expect(dayCard.hasConflict == false, "Same city doubleheader is not a conflict")
}
@Test("DayCard selects stop with game when first stop has no game on that day")
func dayCard_SelectsStopWithGame_WhenFirstStopHasNoGame() {
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
// First stop has game on different day
let firstStopGameId = UUID()
let firstStopGameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
let firstStopGame = makeGame(id: firstStopGameId, dateTime: firstStopGameTime, stadiumId: UUID())
// Second stop has game on Apr 4
let secondStopGameId = UUID()
let secondStopGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
let secondStopGame = makeGame(id: secondStopGameId, dateTime: secondStopGameTime, stadiumId: UUID())
let firstStop = makeStop(city: "Philadelphia", arrivalDate: apr4, departureDate: apr5, games: [firstStopGameId])
let secondStop = makeStop(city: "Baltimore", arrivalDate: apr4, departureDate: apr5, games: [secondStopGameId])
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [firstStop, secondStop], travelSegments: [])
let games: [UUID: RichGame] = [
firstStopGameId: makeRichGame(game: firstStopGame),
secondStopGameId: makeRichGame(game: secondStopGame)
]
let dayCard = DayCard(day: day, games: games)
// Should select Baltimore (has game on Apr 4) not Philadelphia (game on Apr 5)
#expect(dayCard.gamesOnThisDay.count == 1)
#expect(dayCard.gamesOnThisDay.first?.game.id == secondStopGameId)
#expect(dayCard.primaryCityForDay == "Baltimore")
}
// MARK: - DayConflictInfo Tests
@Test("DayConflictInfo with no conflict has empty warning")
func dayConflictInfo_NoConflict_EmptyWarning() {
let conflictInfo = DayConflictInfo(
hasConflict: false,
conflictingStops: [],
conflictingCities: []
)
#expect(conflictInfo.hasConflict == false)
#expect(conflictInfo.warningMessage.isEmpty)
}
}
// MARK: - Duplicate Game ID Regression Tests
/// Tests for handling duplicate game IDs without crashing (regression test for fatal error)
struct DuplicateGameIdTests {
private func makeStadium() -> Stadium {
Stadium(
id: UUID(),
name: "Test Stadium",
city: "Test City",
state: "TS",
latitude: 40.0,
longitude: -100.0,
capacity: 40000
)
}
private func makeTeam(sport: Sport = .mlb, stadiumId: UUID) -> Team {
Team(
id: UUID(),
name: "Test Team",
abbreviation: "TST",
sport: sport,
city: "Test City",
stadiumId: stadiumId
)
}
private func makeGame(id: UUID, homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID, dateTime: Date) -> Game {
Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: .mlb,
season: "2026"
)
}
@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")
}
@Test("Duplicate games are deduplicated at load time")
func gamesArray_DeduplicatesById() {
// Simulate the deduplication logic used in StubDataProvider
let gameId = UUID()
let dateTime = Date()
let game1 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime)
let game2 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime.addingTimeInterval(3600))
let games = [game1, game2]
// Deduplication logic from StubDataProvider
var seenIds = Set<UUID>()
let uniqueGames = games.filter { game in
if seenIds.contains(game.id) {
return false
}
seenIds.insert(game.id)
return true
}
#expect(uniqueGames.count == 1)
#expect(uniqueGames.first?.dateTime == game1.dateTime, "First occurrence should be kept")
}
}

View File

@@ -0,0 +1,530 @@
//
// 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")
}
}