diff --git a/SportsTimeTests/ScenarioCPlannerTests.swift b/SportsTimeTests/ScenarioCPlannerTests.swift index be798cc..95ce344 100644 --- a/SportsTimeTests/ScenarioCPlannerTests.swift +++ b/SportsTimeTests/ScenarioCPlannerTests.swift @@ -2273,4 +2273,410 @@ struct ScenarioCPlannerTests { } } } + + // MARK: - Feature 2: Geographic Efficiency Validation (Anti-Backtracking) Tests + + @Test("Route must start at specified start city") + func antiBacktrack_RouteStartsAtSpecifiedCity() { + let planner = ScenarioCPlanner() + + // SF → LA route (south-bound) + let sf = sfStadium + let la = laStadium + + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-06-07 19:00")) + let laGame = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) // Earlier date + + let startLoc = LocationInput( + name: "San Francisco", + coordinate: CLLocationCoordinate2D(latitude: sf.latitude, longitude: sf.longitude) + ) + let endLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + + let request = makeRequest( + games: [laGame, sfGame], + stadiums: [la.id: la, sf.id: sf], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // The route should visit SF before LA (not LA first just because it's earlier) + // First stop with games should be SF + if let topOption = options.first { + let stopsWithGames = topOption.stops.filter { !$0.games.isEmpty } + if let firstGameStop = stopsWithGames.first { + #expect(firstGameStop.city == "San Francisco", "Route should start at SF (specified start city)") + } + } + } else { + Issue.record("Expected success with route starting at SF") + } + } + + @Test("Route must end at specified end city") + func antiBacktrack_RouteEndsAtSpecifiedCity() { + let planner = ScenarioCPlanner() + + // LA → Seattle route + let la = laStadium + let sf = sfStadium + let seattle = makeStadium(name: "T-Mobile Park", city: "Seattle", state: "WA", + latitude: 47.5914, longitude: -122.3325) + + let laGame = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-06-06 19:00")) + + let startLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + let endLoc = LocationInput( + name: "Seattle", + coordinate: CLLocationCoordinate2D(latitude: seattle.latitude, longitude: seattle.longitude) + ) + + let request = makeRequest( + games: [laGame, sfGame], + stadiums: [la.id: la, sf.id: sf, seattle.id: seattle], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // Route should end at Seattle waypoint (after all games) + if let topOption = options.first { + // The last stop should be Seattle (or close to it) + let lastStop = topOption.stops.last + #expect(lastStop != nil, "Route should have stops") + + // The route should include Seattle as the end destination + // Either as a waypoint or the last stop should be very close to Seattle + if let lastStop = lastStop, let lastCoord = lastStop.coordinate { + let lastLocation = CLLocation(latitude: lastCoord.latitude, longitude: lastCoord.longitude) + let seattleLocation = CLLocation(latitude: seattle.latitude, longitude: seattle.longitude) + let distance = lastLocation.distance(from: seattleLocation) + + // Should either be Seattle itself or within 80km (for waypoint tolerance) + #expect(distance < 80000, "Route should end at or near Seattle (end city)") + } + } + } else { + Issue.record("Expected success with route ending at Seattle") + } + } + + @Test("Intermediate games in wrong order rejected or reordered") + func antiBacktrack_WrongOrderGamesHandled() { + let planner = ScenarioCPlanner() + + // LA → Portland route + let la = laStadium + let sf = sfStadium + let portland = makeStadium(name: "Providence Park", city: "Portland", state: "OR", + latitude: 45.5212, longitude: -122.6917) + + // Games in suboptimal order: SF first, then LA (backtrack south), then Portland + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-06-05 19:00")) + let laGame = makeGame(stadiumId: la.id, date: date("2026-06-06 19:00")) // Would require backtrack + let portlandGame = makeGame(stadiumId: portland.id, date: date("2026-06-07 19:00")) + + let startLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + let endLoc = LocationInput( + name: "Portland", + coordinate: CLLocationCoordinate2D(latitude: portland.latitude, longitude: portland.longitude) + ) + + let request = makeRequest( + games: [sfGame, laGame, portlandGame], + stadiums: [la.id: la, sf.id: sf, portland.id: portland], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // The route should either: + // A) Exclude the LA game on June 6 (since it requires backtracking south after SF) + // B) Reorder to LA → SF → Portland (optimal order) + + if let topOption = options.first { + let gameIds = topOption.stops.flatMap { $0.games } + + // If it includes all 3 games, check the order is sensible (LA before SF) + if gameIds.count == 3 { + let stopsWithGames = topOption.stops.filter { !$0.games.isEmpty } + if stopsWithGames.count >= 2 { + let firstCity = stopsWithGames[0].city + let secondCity = stopsWithGames[1].city + + // LA should come before SF + #expect( + (firstCity == "Los Angeles" && secondCity == "San Francisco"), + "If all games included, LA should come before SF (no backtracking)" + ) + } + } + // Otherwise, it's acceptable to exclude the backtracking game + } + } else { + Issue.record("Expected success with sensible ordering or game exclusion") + } + } + + @Test("Multiple route options - least backtracking preferred") + func antiBacktrack_LeastBacktrackingPreferred() { + let planner = ScenarioCPlanner() + + // LA → SF route + let la = laStadium + let sd = sdStadium // South of LA - major backtrack + let sj = makeStadium(name: "SAP Center", city: "San Jose", state: "CA", + latitude: 37.3387, longitude: -121.8853) + let sf = sfStadium + + // Two potential routes: + // Option A: LA → SD (south backtrack) → SF (requires going way south first) + // Option B: LA → SJ → SF (direct north) + + let laGame1 = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) + let sdGame = makeGame(stadiumId: sd.id, date: date("2026-06-06 19:00")) + let laGame2 = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) // Duplicate for Option B + let sjGame = makeGame(stadiumId: sj.id, date: date("2026-06-06 19:00")) + let sfGame1 = makeGame(stadiumId: sf.id, date: date("2026-06-07 19:00")) + let sfGame2 = makeGame(stadiumId: sf.id, date: date("2026-06-07 19:00")) + + let startLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + let endLoc = LocationInput( + name: "San Francisco", + coordinate: CLLocationCoordinate2D(latitude: sf.latitude, longitude: sf.longitude) + ) + + let request = makeRequest( + games: [laGame1, sdGame, sjGame, sfGame1], + stadiums: [la.id: la, sd.id: sd, sj.id: sj, sf.id: sf], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // Top option should prefer LA → SJ → SF (direct) over LA → SD → SF (backtrack) + if let topOption = options.first { + let gameIds = topOption.stops.flatMap { $0.games } + + // Should prefer SJ over SD (less backtracking) + if gameIds.contains(sjGame.id) && gameIds.contains(sdGame.id) { + Issue.record("Should not include both SJ and SD - prefer less backtracking") + } + + // Better: should include SJ and exclude SD + #expect(gameIds.contains(sjGame.id) || !gameIds.contains(sdGame.id), + "Should prefer San Jose (direct north) over San Diego (backtrack south)") + } + } else { + Issue.record("Expected success with least-backtracking route preferred") + } + } + + @Test("Minor backtracking within tolerance is acceptable") + func antiBacktrack_MinorBacktrackingAcceptable() { + let planner = ScenarioCPlanner() + + // LA → SF route with minor backtrack to Anaheim + let la = laStadium + let anaheim = makeStadium(name: "Honda Center", city: "Anaheim", state: "CA", + latitude: 33.8078, longitude: -117.8764) // ~30mi south of LA + let sj = makeStadium(name: "SAP Center", city: "San Jose", state: "CA", + latitude: 37.3387, longitude: -121.8853) + let sf = sfStadium + + let laGame = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) + let anaheimGame = makeGame(stadiumId: anaheim.id, date: date("2026-06-06 19:00")) + let sjGame = makeGame(stadiumId: sj.id, date: date("2026-06-07 19:00")) + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-06-08 19:00")) + + let startLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + let endLoc = LocationInput( + name: "San Francisco", + coordinate: CLLocationCoordinate2D(latitude: sf.latitude, longitude: sf.longitude) + ) + + let request = makeRequest( + games: [laGame, anaheimGame, sjGame, sfGame], + stadiums: [la.id: la, anaheim.id: anaheim, sj.id: sj, sf.id: sf], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // Anaheim is only ~30 miles south, which should be acceptable as a minor detour + // The route should include Anaheim if time permits + if let topOption = options.first { + let gameIds = topOption.stops.flatMap { $0.games } + + // Anaheim could be included (acceptable minor backtrack) + // This test just verifies we don't fail completely + #expect(true, "Route planning should succeed with minor backtracking scenario") + } + } else { + Issue.record("Expected success - minor backtracking should be acceptable") + } + } + + @Test("Excessive backtracking beyond destination rejected") + func antiBacktrack_ExcessiveBacktrackingRejected() { + let planner = ScenarioCPlanner() + + // LA → Seattle route (north-bound) + let la = laStadium + let sd = sdStadium // 120 miles south - excessive backtrack + let sf = sfStadium + let seattle = makeStadium(name: "T-Mobile Park", city: "Seattle", state: "WA", + latitude: 47.5914, longitude: -122.3325) + + let laGame = makeGame(stadiumId: la.id, date: date("2026-06-05 19:00")) + let sdGame = makeGame(stadiumId: sd.id, date: date("2026-06-06 19:00")) // Excessive backtrack + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-06-07 19:00")) + let seattleGame = makeGame(stadiumId: seattle.id, date: date("2026-06-08 19:00")) + + let startLoc = LocationInput( + name: "Los Angeles", + coordinate: CLLocationCoordinate2D(latitude: la.latitude, longitude: la.longitude) + ) + let endLoc = LocationInput( + name: "Seattle", + coordinate: CLLocationCoordinate2D(latitude: seattle.latitude, longitude: seattle.longitude) + ) + + let request = makeRequest( + games: [laGame, sdGame, sfGame, seattleGame], + stadiums: [la.id: la, sd.id: sd, sf.id: sf, seattle.id: seattle], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + // Either exclude San Diego (success without it) or return failure + if case .success(let options) = result { + #expect(!options.isEmpty) + + // San Diego should be excluded (excessive backtrack going north) + let allGameIds = options.flatMap { $0.stops.flatMap { $0.games } } + #expect(!allGameIds.contains(sdGame.id), + "San Diego should be excluded (120mi south, excessive backtrack on north-bound route)") + } + // Alternatively, could fail with noValidRoutes if San Diego is required + } + + @Test("Correct directional classification for north-to-south route") + func antiBacktrack_NorthToSouthDirectionalCorrect() { + let planner = ScenarioCPlanner() + + // Boston → Miami route (north to south) + let boston = makeStadium(name: "TD Garden", city: "Boston", state: "MA", + latitude: 42.3662, longitude: -71.0621) + let nyc = makeStadium(name: "Madison Square Garden", city: "New York", state: "NY", + latitude: 40.7505, longitude: -73.9934) + let dc = makeStadium(name: "Capital One Arena", city: "Washington", state: "DC", + latitude: 38.8981, longitude: -77.0209) + let miami = makeStadium(name: "FTX Arena", city: "Miami", state: "FL", + latitude: 25.7814, longitude: -80.1870) + + let bostonGame = makeGame(stadiumId: boston.id, date: date("2026-06-05 19:00")) + let nycGame = makeGame(stadiumId: nyc.id, date: date("2026-06-06 19:00")) + let dcGame = makeGame(stadiumId: dc.id, date: date("2026-06-07 19:00")) + let miamiGame = makeGame(stadiumId: miami.id, date: date("2026-06-08 19:00")) + + let startLoc = LocationInput( + name: "Boston", + coordinate: CLLocationCoordinate2D(latitude: boston.latitude, longitude: boston.longitude) + ) + let endLoc = LocationInput( + name: "Miami", + coordinate: CLLocationCoordinate2D(latitude: miami.latitude, longitude: miami.longitude) + ) + + let request = makeRequest( + games: [bostonGame, nycGame, dcGame, miamiGame], + stadiums: [boston.id: boston, nyc.id: nyc, dc.id: dc, miami.id: miami], + startLocation: startLoc, + endLocation: endLoc, + startDate: date("2026-06-01 00:00"), + endDate: date("2026-06-30 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty) + + // Route should follow north→south progression + if let topOption = options.first { + let stopsWithGames = topOption.stops.filter { !$0.games.isEmpty } + + // Verify stops are in geographic north→south order + if stopsWithGames.count >= 2 { + for i in 0..<(stopsWithGames.count - 1) { + guard let currentCoord = stopsWithGames[i].coordinate, + let nextCoord = stopsWithGames[i + 1].coordinate else { + continue + } + + let currentLat = currentCoord.latitude + let nextLat = nextCoord.latitude + + // Each subsequent stop should be equal or farther south (lower latitude) + #expect(nextLat <= currentLat + 1.0, // Allow 1° tolerance for slight variations + "Route should progress south (Boston→NYC→DC→Miami)") + } + } + } + } else { + Issue.record("Expected success with north→south directional route") + } + } }