diff --git a/SportsTimeTests/ScenarioBPlannerTests.swift b/SportsTimeTests/ScenarioBPlannerTests.swift index 98bb342..cf090bf 100644 --- a/SportsTimeTests/ScenarioBPlannerTests.swift +++ b/SportsTimeTests/ScenarioBPlannerTests.swift @@ -1438,4 +1438,396 @@ struct ScenarioBPlannerTests { Issue.record("Expected success") } } + + // MARK: - Filler Game Conflict Tests (Phase 09-02) + + @Test("filler game between must-see games is included when feasible") + func plan_FillerBetweenAnchors_IncludedWhenFeasible() { + let planner = ScenarioBPlanner() + let la = laStadium + let sf = sfStadium + + // Create San Jose stadium (between LA and SF) + let sjStadium = makeStadium( + name: "San Jose Stadium", + city: "San Jose", + state: "CA", + latitude: 37.3382, + longitude: -121.8863 + ) + + // Must-see: LA Jan 5 1pm, SF Jan 7 7pm + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 13:00")) + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-01-07 19:00")) + + // Filler: San Jose Jan 6 7pm (between LA and SF, feasible timing) + let sjGame = makeGame(stadiumId: sjStadium.id, date: date("2026-01-06 19:00")) + + let request = makeRequest( + games: [laGame, sfGame, sjGame], + stadiums: [la.id: la, sf.id: sf, sjStadium.id: sjStadium], + mustSeeGameIds: [laGame.id, sfGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + // Should have a 3-stop route: LA → SJ → SF + let threeStopOption = options.first { $0.stops.count == 3 } + #expect(threeStopOption != nil, "Should have route with filler game included") + + if let option = threeStopOption { + let cities = option.stops.map { $0.city } + #expect(cities.contains("San Jose"), "Filler city should be included") + } + } else { + Issue.record("Expected success") + } + } + + @Test("filler game same-day as must-see is excluded") + func plan_FillerSameDayAsAnchor_Excluded() { + let planner = ScenarioBPlanner() + let la = laStadium + + // Create Anaheim stadium (30 miles from LA) + let anaheimStadium = makeStadium( + name: "Angel Stadium", + city: "Anaheim", + state: "CA", + latitude: 33.8003, + longitude: -117.8827 + ) + + // Must-see: LA Jan 5 7pm + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 19:00")) + + // Filler: Anaheim Jan 5 7pm (same time, different city - impossible) + let anaheimGame = makeGame(stadiumId: anaheimStadium.id, date: date("2026-01-05 19:00")) + + let request = makeRequest( + games: [laGame, anaheimGame], + stadiums: [la.id: la, anaheimStadium.id: anaheimStadium], + mustSeeGameIds: [laGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + // All options must include LA game (anchor) + for option in options { + let allGameIds = option.stops.flatMap { $0.games } + #expect(allGameIds.contains(laGame.id), "Anchor game must be present") + + // If both games in same option, they cannot be at same time + if allGameIds.contains(anaheimGame.id) { + Issue.record("Filler game at same time as anchor should be excluded") + } + } + } else { + Issue.record("Expected success") + } + } + + @Test("filler requiring backtracking is excluded or route reordered") + func plan_FillerRequiringBacktrack_ExcludedOrReordered() { + let planner = ScenarioBPlanner() + let la = laStadium + let sf = sfStadium + let sd = sdStadium // San Diego (south of LA) + + // Must-see: LA Jan 5 7pm, SF Jan 7 7pm (northbound route) + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 19:00")) + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-01-07 19:00")) + + // Filler: San Diego Jan 6 7pm (south of LA, requires backtrack from northbound route) + let sdGame = makeGame(stadiumId: sd.id, date: date("2026-01-06 19:00")) + + let request = makeRequest( + games: [laGame, sfGame, sdGame], + stadiums: [la.id: la, sf.id: sf, sd.id: sd], + mustSeeGameIds: [laGame.id, sfGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + // Check if SD is included in any route + for option in options { + let cities = option.stops.map { $0.city } + if cities.contains("San Diego") { + // If SD included, route should be reordered: SD → LA → SF + // OR it should be a separate option + if cities.count == 3 { + // Check it's not inefficient backtracking: LA → SF → SD or LA → SD → SF (backtrack) + let cityOrder = cities.joined(separator: "→") + #expect( + cityOrder == "San Diego→Los Angeles→San Francisco", + "If filler included, route should avoid backtracking" + ) + } + } + } + } else { + Issue.record("Expected success") + } + } + + @Test("multiple filler options only feasible one included") + func plan_MultipleFillers_OnlyFeasibleIncluded() { + let planner = ScenarioBPlanner() + let la = laStadium + let phoenix = phoenixStadium + + // Create Tucson (between LA and Phoenix) + let tucsonStadium = makeStadium( + name: "Tucson Stadium", + city: "Tucson", + state: "AZ", + latitude: 32.2226, + longitude: -110.9747 + ) + + // Must-see: LA Jan 5 1pm, Phoenix Jan 7 7pm (eastbound) + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 13:00")) + let phoenixGame = makeGame(stadiumId: phoenix.id, date: date("2026-01-07 19:00")) + + // Filler A: SF Jan 6 7pm (300mi north, wrong direction from LA→Phoenix) + let sfGame = makeGame(stadiumId: sfStadium.id, date: date("2026-01-06 19:00")) + + // Filler B: Tucson Jan 6 7pm (100mi from Phoenix, on the way) + let tucsonGame = makeGame(stadiumId: tucsonStadium.id, date: date("2026-01-06 19:00")) + + let request = makeRequest( + games: [laGame, phoenixGame, sfGame, tucsonGame], + stadiums: [la.id: la, phoenix.id: phoenix, sfStadium.id: sfStadium, tucsonStadium.id: tucsonStadium], + mustSeeGameIds: [laGame.id, phoenixGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + // Look for routes with 3 stops + let threeStopOptions = options.filter { $0.stops.count == 3 } + + for option in threeStopOptions { + let cities = option.stops.map { $0.city } + + // If Tucson included, SF should not be (wrong direction) + if cities.contains("Tucson") { + #expect(!cities.contains("San Francisco"), + "Should not include SF when Tucson is better option") + } + + // If SF included, Tucson should not be (SF is wrong direction) + if cities.contains("San Francisco") { + #expect(!cities.contains("Tucson"), + "Should not include Tucson when SF chosen (though Tucson is better)") + } + } + } else { + Issue.record("Expected success") + } + } + + // MARK: - Impossible Geographic Combination Tests (Phase 09-02) + + @Test("must-see games too far apart for date span fail") + func plan_MustSeeGamesTooFarApart_Fails() { + let planner = ScenarioBPlanner() + + // Create NY and LA stadiums (2800 miles, 42 hour drive) + let nyStadium = makeStadium( + name: "Yankee Stadium", + city: "New York", + state: "NY", + latitude: 40.8296, + longitude: -73.9262 + ) + let laStadium = self.laStadium + + // Must-see: LA Jan 5 7pm, NY Jan 6 7pm (24 hours available) + let laGame = makeGame(stadiumId: laStadium.id, date: date("2026-01-05 19:00")) + let nyGame = makeGame(stadiumId: nyStadium.id, date: date("2026-01-06 19:00")) + + let request = makeRequest( + games: [laGame, nyGame], + stadiums: [laStadium.id: laStadium, nyStadium.id: nyStadium], + mustSeeGameIds: [laGame.id, nyGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .failure(let failure) = result { + // Expected - cannot drive 2800mi in 24hr + #expect(failure.reason == .constraintsUnsatisfiable || + failure.reason == .drivingExceedsLimit) + } else { + Issue.record("Expected failure - impossible to drive LA to NY in 24 hours") + } + } + + @Test("must-see games in reverse order geographically fail") + func plan_MustSeeGamesReverseOrder_Fails() { + let planner = ScenarioBPlanner() + let la = laStadium + let sf = sfStadium + + // Must-see: SF Jan 5, LA Jan 4 + // Problem: SF game is chronologically after LA, but geographically north + // This requires backtracking or violates date ordering + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-01-05 19:00")) + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-04 19:00")) + + let request = makeRequest( + games: [sfGame, laGame], + stadiums: [la.id: la, sf.id: sf], + mustSeeGameIds: [sfGame.id, laGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + // This may succeed OR fail depending on routing logic + // If it succeeds, verify route respects chronology (LA → SF) + if case .success(let options) = result { + for option in options { + if option.stops.count == 2 { + // Route should be chronological: LA (Jan 4) → SF (Jan 5) + #expect(option.stops[0].city == "Los Angeles") + #expect(option.stops[1].city == "San Francisco") + } + } + } + // Failure is also acceptable + } + + @Test("three must-see games forming triangle routes or fails") + func plan_ThreeMustSeeTriangle_RoutesOrFails() { + let planner = ScenarioBPlanner() + let la = laStadium + let sf = sfStadium + let sd = sdStadium + + // Triangle: LA Jan 5, SF Jan 6, SD Jan 7 + // Inefficient: LA→SF→SD requires backtracking south + // Better: SD→LA→SF (but violates chronology) + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 19:00")) + let sfGame = makeGame(stadiumId: sf.id, date: date("2026-01-06 19:00")) + let sdGame = makeGame(stadiumId: sd.id, date: date("2026-01-07 19:00")) + + let request = makeRequest( + games: [laGame, sfGame, sdGame], + stadiums: [la.id: la, sf.id: sf, sd.id: sd], + mustSeeGameIds: [laGame.id, sfGame.id, sdGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + // Should attempt route in chronological order: LA→SF→SD + let threeStopOption = options.first { $0.stops.count == 3 } + if let option = threeStopOption { + // Verify chronological order is respected + #expect(option.stops[0].city == "Los Angeles") + #expect(option.stops[1].city == "San Francisco") + #expect(option.stops[2].city == "San Diego") + } + } + // Failure is also acceptable if deemed too inefficient + } + + @Test("must-see games exceeding driving constraints fail") + func plan_MustSeeExceedingDrivingLimit_Fails() { + let planner = ScenarioBPlanner() + let la = laStadium + let phoenix = phoenixStadium + + // Must-see: LA Jan 5 1pm, Phoenix Jan 5 7pm (380 miles, 6hr drive) + // Constraints: 1 driver, 4hr/day max + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 13:00")) + let phoenixGame = makeGame(stadiumId: phoenix.id, date: date("2026-01-05 19:00")) + + // Create custom request with reduced driving limit + var prefs = TripPreferences( + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59"), + tripDuration: nil, + numberOfDrivers: 1 + ) + prefs.mustSeeGameIds = [laGame.id, phoenixGame.id] + prefs.maxDrivingHoursPerDriver = 4.0 // Limit to 4 hours + + let request = PlanningRequest( + preferences: prefs, + availableGames: [laGame, phoenixGame], + teams: [:], + stadiums: [la.id: la, phoenix.id: phoenix] + ) + + let result = planner.plan(request: request) + + if case .failure(let failure) = result { + // Expected - 6hr drive exceeds 4hr limit + #expect(failure.reason == .drivingExceedsLimit || + failure.reason == .constraintsUnsatisfiable) + } else { + Issue.record("Expected failure - 6hr drive exceeds 4hr daily limit") + } + } + + @Test("feasible must-see combination succeeds (sanity)") + func plan_FeasibleMustSeeCombination_Succeeds() { + let planner = ScenarioBPlanner() + let la = laStadium + + // Create Anaheim (30 miles from LA, easy drive) + let anaheimStadium = makeStadium( + name: "Angel Stadium", + city: "Anaheim", + state: "CA", + latitude: 33.8003, + longitude: -117.8827 + ) + + // Must-see: LA Jan 5 7pm, Anaheim Jan 7 7pm (plenty of time) + let laGame = makeGame(stadiumId: la.id, date: date("2026-01-05 19:00")) + let anaheimGame = makeGame(stadiumId: anaheimStadium.id, date: date("2026-01-07 19:00")) + + let request = makeRequest( + games: [laGame, anaheimGame], + stadiums: [la.id: la, anaheimStadium.id: anaheimStadium], + mustSeeGameIds: [laGame.id, anaheimGame.id], + startDate: date("2026-01-01 00:00"), + endDate: date("2026-01-31 23:59") + ) + + let result = planner.plan(request: request) + + if case .success(let options) = result { + #expect(!options.isEmpty, "Feasible must-see combination should succeed") + + // Verify both games appear in route + for option in options { + let allGameIds = option.stops.flatMap { $0.games } + #expect(allGameIds.contains(laGame.id)) + #expect(allGameIds.contains(anaheimGame.id)) + } + } else { + Issue.record("Expected success for feasible must-see combination") + } + } }