// // ScenarioCPlannerTests.swift // SportsTimeTests // // TDD specification tests for ScenarioCPlanner. // import Testing import CoreLocation @testable import SportsTime @Suite("ScenarioCPlanner") struct ScenarioCPlannerTests { // MARK: - Test Data private let planner = ScenarioCPlanner() // Coordinates: Chicago -> Cleveland -> New York (west to east) private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233) private let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4995, longitude: -81.6954) private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) private let laCoord = CLLocationCoordinate2D(latitude: 34.0430, longitude: -118.2673) // MARK: - Specification Tests: Missing Start Location @Test("plan: no start location returns missingLocations failure") func plan_noStartLocation_returnsMissingLocations() { let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: nil, // Missing endLocation: endLocation, sports: [.mlb], startDate: TestClock.now, endDate: TestClock.now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when start location missing") return } #expect(failure.reason == .missingLocations) } // MARK: - Specification Tests: Missing End Location @Test("plan: no end location returns missingLocations failure") func plan_noEndLocation_returnsMissingLocations() { let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: nil, // Missing sports: [.mlb], startDate: TestClock.now, endDate: TestClock.now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when end location missing") return } #expect(failure.reason == .missingLocations) } // MARK: - Specification Tests: Missing Coordinates @Test("plan: locations without coordinates returns missingLocations") func plan_locationsWithoutCoordinates_returnsMissingLocations() { let startLocation = LocationInput(name: "Chicago", coordinate: nil) // No coord let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: TestClock.now, endDate: TestClock.now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: [:] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when coordinates missing") return } #expect(failure.reason == .missingLocations) } // MARK: - Specification Tests: No Stadiums in Cities @Test("plan: no stadiums in start city returns noGamesInRange") func plan_noStadiumsInStartCity_returnsFailure() { let startLocation = LocationInput(name: "Nowhere", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: TestClock.now, endDate: TestClock.now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: ["nyc": nycStadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when no stadiums in start city") return } #expect(failure.reason == .noGamesInRange) } @Test("plan: no stadiums in end city returns noGamesInRange") func plan_noStadiumsInEndCity_returnsFailure() { let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "Nowhere", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: TestClock.now, endDate: TestClock.now.addingTimeInterval(86400 * 7), leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 1 ) let request = PlanningRequest( preferences: prefs, availableGames: [], teams: [:], stadiums: ["chicago": chicagoStadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when no stadiums in end city") return } #expect(failure.reason == .noGamesInRange) } @Test("plan: city names with state suffixes match stadium city names") func plan_cityNamesWithStateSuffixes_matchStadiumCities() { let baseDate = TestClock.now let endDate = baseDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: baseDate.addingTimeInterval(86400 * 1)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: baseDate.addingTimeInterval(86400 * 4)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: baseDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, nycGame], teams: [:], stadiums: ["chicago": chicagoStadium, "nyc": nycStadium] ) let result = planner.plan(request: request) guard case .success = result else { Issue.record("Expected success with city/state location labels matching plain stadium cities") return } } // MARK: - Specification Tests: Directional Filtering @Test("plan: directional filtering includes stadiums toward destination") func plan_directionalFiltering_includesCorrectStadiums() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 14) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) // Stadiums let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) // Between Chicago and NYC let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let laStadium = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) // Wrong direction // Games let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 3)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 6)) let laGame = makeGame(id: "la-game", stadiumId: "la", dateTime: startDate.addingTimeInterval(86400 * 4)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, clevelandGame, nycGame, laGame], teams: [:], stadiums: [ "chicago": chicagoStadium, "cleveland": clevelandStadium, "nyc": nycStadium, "la": laStadium ] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } // LA game should NOT be in any route (wrong direction) #expect(!allGameIds.contains("la-game"), "LA game should be filtered out (wrong direction)") } // MARK: - Specification Tests: Start/End Stops @Test("plan: adds start and end as non-game stops") func plan_addsStartEndAsNonGameStops() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 3)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, clevelandGame, nycGame], teams: [:], stadiums: [ "chicago": chicagoStadium, "cleveland": clevelandStadium, "nyc": nycStadium ] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { // First stop should be start city #expect(option.stops.first?.city == "Chicago", "First stop should be start city") // Last stop should be end city #expect(option.stops.last?.city == "New York", "Last stop should be end city") } } // MARK: - Invariant Tests @Test("Invariant: start stop has no games") func invariant_startStopHasNoGames() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, nycGame], teams: [:], stadiums: ["chicago": chicagoStadium, "nyc": nycStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { // When start city (Chicago) has a game, the endpoint is merged into the game stop. // Verify the first stop IS Chicago (either as game stop or endpoint). #expect(option.stops.first?.city == "Chicago", "First stop should be the start city (Chicago)") // Verify the last stop is the end city #expect(option.stops.last?.city == "New York", "Last stop should be the end city (New York)") } } @Test("Invariant: end stop appears last") func invariant_endStopAppearsLast() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, nycGame], teams: [:], stadiums: ["chicago": chicagoStadium, "nyc": nycStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { #expect(option.stops.last?.city == "New York", "End city must be last stop") } } // MARK: - Property Tests @Test("Property: forward progress tolerance filters distant backward stadiums") func property_forwardProgressTolerance() { // Chicago → NYC route. LA is far backward (west), should be excluded. // Cleveland is forward (east of Chicago, toward NYC), should be included. let chicagoStad = makeStadium(id: "chi", city: "Chicago", coordinate: chicagoCoord) let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let clevelandCoord = CLLocationCoordinate2D(latitude: 41.4958, longitude: -81.6853) let clevelandStad = makeStadium(id: "cle", city: "Cleveland", coordinate: clevelandCoord) let laCoord = CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400) let laStad = makeStadium(id: "la", city: "Los Angeles", coordinate: laCoord) let chiGame = makeGame(id: "g_chi", stadiumId: "chi", dateTime: TestClock.addingDays(1)) let cleGame = makeGame(id: "g_cle", stadiumId: "cle", dateTime: TestClock.addingDays(3)) let laGame = makeGame(id: "g_la", stadiumId: "la", dateTime: TestClock.addingDays(4)) let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(6)) let prefs = TripPreferences( planningMode: .locations, startLocation: LocationInput(name: "Chicago", coordinate: chicagoCoord), endLocation: LocationInput(name: "New York", coordinate: nycCoord), sports: [.mlb], startDate: TestClock.now, endDate: TestClock.addingDays(10), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chiGame, cleGame, laGame, nycGame], teams: [:], stadiums: ["chi": chicagoStad, "nyc": nycStad, "cle": clevelandStad, "la": laStad] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let cities = option.stops.map(\.city) #expect(!cities.contains("Los Angeles"), "LA is far backward from Chicago→NYC route and should be excluded") } } // MARK: - Regression Tests: Endpoint Merging private let houstonCoord = CLLocationCoordinate2D(latitude: 29.7604, longitude: -95.3698) private let denverCoord = CLLocationCoordinate2D(latitude: 39.7392, longitude: -104.9903) @Test("plan: start city matches first game city — no redundant empty endpoint") func plan_startCityMatchesFirstGameCity_noZeroMileTravel() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Houston, TX", coordinate: houstonCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let houstonStadium = makeStadium(id: "houston", city: "Houston", coordinate: houstonCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let houstonGame = makeGame(id: "hou-game", stadiumId: "houston", dateTime: startDate.addingTimeInterval(86400)) let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [houstonGame, clevelandGame, nycGame], teams: [:], stadiums: [ "houston": houstonStadium, "cleveland": clevelandStadium, "nyc": nycStadium ] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } #expect(!options.isEmpty, "Should produce at least one itinerary") for option in options { // When the route includes a Houston game stop, there should NOT also be // a separate empty Houston endpoint stop (the fix merges them) let houstonStops = option.stops.filter { $0.city == "Houston" || $0.city == "Houston, TX" } let emptyHoustonStops = houstonStops.filter { !$0.hasGames } let gameHoustonStops = houstonStops.filter { $0.hasGames } if !gameHoustonStops.isEmpty { #expect(emptyHoustonStops.isEmpty, "Should not have both a game stop and empty endpoint in Houston") } } } @Test("plan: both endpoints match game cities — no redundant empty endpoints") func plan_bothEndpointsMatchGameCities_noEmptyStops() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago, IL", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York, NY", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 4)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, clevelandGame, nycGame], teams: [:], stadiums: [ "chicago": chicagoStadium, "cleveland": clevelandStadium, "nyc": nycStadium ] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } #expect(!options.isEmpty, "Should produce at least one itinerary") for option in options { // When a route includes a game in an endpoint city, // there should NOT also be a separate empty endpoint stop for that city let chicagoStops = option.stops.filter { $0.city == "Chicago" || $0.city == "Chicago, IL" } if chicagoStops.contains(where: { $0.hasGames }) { #expect(!chicagoStops.contains(where: { !$0.hasGames }), "No redundant empty Chicago endpoint when game stop exists") } let nycStops = option.stops.filter { $0.city == "New York" || $0.city == "New York, NY" } if nycStops.contains(where: { $0.hasGames }) { #expect(!nycStops.contains(where: { !$0.hasGames }), "No redundant empty NYC endpoint when game stop exists") } } } @Test("plan: start city differs from all game cities — adds empty endpoint stop") func plan_endpointDiffersFromGameCity_stillAddsEndpointStop() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) // Start from a city that has a stadium but the route games are elsewhere // Use Pittsburgh as an intermediate that differs from Chicago start let pittsburghCoord = CLLocationCoordinate2D(latitude: 40.4406, longitude: -79.9959) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "New York", coordinate: nycCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let pittsburghStadium = makeStadium(id: "pittsburgh", city: "Pittsburgh", coordinate: pittsburghCoord) let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) // Chicago game at start, Cleveland game at a non-endpoint city let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let pittsburghGame = makeGame(id: "pit-game", stadiumId: "pittsburgh", dateTime: startDate.addingTimeInterval(86400 * 4)) let nycGame = makeGame(id: "nyc-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 7)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, pittsburghGame, nycGame], teams: [:], stadiums: [ "chicago": chicagoStadium, "pittsburgh": pittsburghStadium, "nyc": nycStadium ] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } #expect(!options.isEmpty) // For routes that include the Chicago game, the start endpoint // should be merged (no separate empty Chicago stop). // For routes that don't include the Chicago game, an empty // Chicago endpoint is correctly added. for option in options { let chicagoStops = option.stops.filter { $0.city == "Chicago" } let hasGameInChicago = chicagoStops.contains { $0.hasGames } let hasEmptyChicago = chicagoStops.contains { !$0.hasGames } // Should never have BOTH an empty endpoint and a game stop for same city #expect(!(hasGameInChicago && hasEmptyChicago), "Should not have both game and empty stops for Chicago") } } // MARK: - Regression Tests: Endpoint Game Validation @Test("plan: explicit date range with no games at end city returns failure") func plan_explicitDateRange_noGamesAtEndCity_returnsFailure() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) // Game at start city, but NO game at end city within date range let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame], teams: [:], stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when no games at end city within date range") return } #expect(failure.reason == .noGamesInRange) #expect(failure.violations.first?.description.contains("Cleveland") == true, "Violation should mention end city") } @Test("plan: explicit date range with no games at start city returns failure") func plan_explicitDateRange_noGamesAtStartCity_returnsFailure() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 7) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) // Game at end city, but NO game at start city within date range let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [clevelandGame], teams: [:], stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] ) let result = planner.plan(request: request) guard case .failure(let failure) = result else { Issue.record("Expected failure when no games at start city within date range") return } #expect(failure.reason == .noGamesInRange) #expect(failure.violations.first?.description.contains("Chicago") == true, "Violation should mention start city") } @Test("plan: explicit date range with games at both cities succeeds") func plan_explicitDateRange_gamesAtBothCities_succeeds() { let startDate = TestClock.now let endDate = startDate.addingTimeInterval(86400 * 10) let startLocation = LocationInput(name: "Chicago", coordinate: chicagoCoord) let endLocation = LocationInput(name: "Cleveland", coordinate: clevelandCoord) let chicagoStadium = makeStadium(id: "chicago", city: "Chicago", coordinate: chicagoCoord) let clevelandStadium = makeStadium(id: "cleveland", city: "Cleveland", coordinate: clevelandCoord) let chicagoGame = makeGame(id: "chi-game", stadiumId: "chicago", dateTime: startDate.addingTimeInterval(86400)) let clevelandGame = makeGame(id: "cle-game", stadiumId: "cleveland", dateTime: startDate.addingTimeInterval(86400 * 5)) let prefs = TripPreferences( planningMode: .locations, startLocation: startLocation, endLocation: endLocation, sports: [.mlb], startDate: startDate, endDate: endDate, leisureLevel: .moderate, lodgingType: .hotel, numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [chicagoGame, clevelandGame], teams: [:], stadiums: ["chicago": chicagoStadium, "cleveland": clevelandStadium] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected success when games exist at both endpoint cities") return } #expect(!options.isEmpty, "Should produce at least one itinerary") } // MARK: - Output Sanity @Test("plan: all stops progress toward end location") func plan_allStopsProgressTowardEnd() { let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) let phillyC = CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665) let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074) let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006) let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC) let phillyStad = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyC) let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC) let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC) let games = [ makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)), makeGame(id: "g_philly", stadiumId: "philly", dateTime: TestClock.addingDays(3)), makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(5)), makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)), ] let startLoc = LocationInput(name: "New York", coordinate: nycC) let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC) let prefs = TripPreferences( planningMode: .locations, startLocation: startLoc, endLocation: endLoc, sports: [.mlb], startDate: TestClock.addingDays(0), endDate: TestClock.addingDays(10), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: games, teams: [:], stadiums: ["nyc": nycStad, "philly": phillyStad, "dc": dcStad, "atl": atlantaStad] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let gameStops = option.stops.filter { !$0.games.isEmpty } for i in 0..<(gameStops.count - 1) { if let coord1 = gameStops[i].coordinate, let coord2 = gameStops[i + 1].coordinate { let progressing = coord2.latitude <= coord1.latitude + 2.0 #expect(progressing, "Stops should progress toward Atlanta (south): \(gameStops[i].city) → \(gameStops[i+1].city)") } } } } @Test("plan: games outside directional cone excluded") func plan_gamesOutsideDirectionalCone_excluded() { let nycC = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855) let atlantaC = CLLocationCoordinate2D(latitude: 33.7553, longitude: -84.4006) let bostonC = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972) let dcC = CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074) let nycStad = makeStadium(id: "nyc", city: "New York", coordinate: nycC) let atlantaStad = makeStadium(id: "atl", city: "Atlanta", coordinate: atlantaC) let bostonStad = makeStadium(id: "boston", city: "Boston", coordinate: bostonC) let dcStad = makeStadium(id: "dc", city: "Washington", coordinate: dcC) let nycGame = makeGame(id: "g_nyc", stadiumId: "nyc", dateTime: TestClock.addingDays(1)) let atlGame = makeGame(id: "g_atl", stadiumId: "atl", dateTime: TestClock.addingDays(7)) let bostonGame = makeGame(id: "g_boston", stadiumId: "boston", dateTime: TestClock.addingDays(3)) let dcGame = makeGame(id: "g_dc", stadiumId: "dc", dateTime: TestClock.addingDays(4)) let startLoc = LocationInput(name: "New York", coordinate: nycC) let endLoc = LocationInput(name: "Atlanta", coordinate: atlantaC) let prefs = TripPreferences( planningMode: .locations, startLocation: startLoc, endLocation: endLoc, sports: [.mlb], startDate: TestClock.addingDays(0), endDate: TestClock.addingDays(10), numberOfDrivers: 2 ) let request = PlanningRequest( preferences: prefs, availableGames: [nycGame, atlGame, bostonGame, dcGame], teams: [:], stadiums: ["nyc": nycStad, "atl": atlantaStad, "boston": bostonStad, "dc": dcStad] ) let result = planner.plan(request: request) guard case .success(let options) = result else { Issue.record("Expected .success, got \(result)") return } for option in options { let cities = option.stops.map(\.city) #expect(!cities.contains("Boston"), "Boston (north of NYC) should be excluded when traveling NYC→Atlanta") } } // MARK: - Helper Methods private func makeStadium( id: String, city: String, coordinate: CLLocationCoordinate2D ) -> Stadium { Stadium( id: id, name: "\(city) Stadium", city: city, state: "XX", latitude: coordinate.latitude, longitude: coordinate.longitude, capacity: 40000, sport: .mlb ) } private func makeGame( id: String, stadiumId: String, dateTime: Date ) -> Game { Game( id: id, homeTeamId: "team1", awayTeamId: "team2", stadiumId: stadiumId, dateTime: dateTime, sport: .mlb, season: "2026", isPlayoff: false ) } }