// // 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") } } }