Files
Sportstime/SportsTime/Planning/Engine/ScenarioPlanner.swift
Trey t 787a0f795e fix: 12 planning engine bugs + App Store preview export at 886x1920
Planning engine fixes (from adversarial code review):
- Bug #1: sortByLeisure tie-breaking uses totalDrivingHours
- Bug #2: allDates/calculateRestDays guard-let-break prevents infinite loop
- Bug #3: same-day trip no longer rejected (>= in dateRange guard)
- Bug #4: ScenarioD rationale shows game count not stop count
- Bug #5: ScenarioD departureDate advanced to next day after last game
- Bug #6: ScenarioC date range boundary uses <= instead of <
- Bug #7: DrivingConstraints clamps maxHoursPerDriverPerDay via max(1.0,...)
- Bug #8: effectiveTripDuration uses inclusive day counting (+1)
- Bug #9: TripWizardViewModel validates endDate >= startDate
- Bug #10: allDates() uses min/max instead of first/last for robustness
- Bug #12: arrivalBeforeGameStart accounts for game end time at departure
- Bug #15: ScenarioBPlanner replaces force unwraps with safe unwrapping

Tests: 16 regression test suites + updated existing test expectations
Marketing: Remotion canvas set to 886x1920 for App Store preview spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:08:50 -06:00

122 lines
4.9 KiB
Swift

//
// ScenarioPlanner.swift
// SportsTime
//
// Protocol for scenario-based trip planning.
//
import Foundation
/// Protocol that all scenario planners must implement.
/// Each scenario (A, B, C, D, E) has its own isolated implementation.
///
/// - Invariants:
/// - Always returns either success or explicit failure, never throws
/// - Success contains ranked itinerary options
/// - Failure contains reason and any constraint violations
protocol ScenarioPlanner {
/// Plan itineraries for this scenario.
/// - Parameter request: The planning request with all inputs
/// - Returns: Success with ranked itineraries, or explicit failure
func plan(request: PlanningRequest) -> ItineraryResult
}
/// Factory for creating the appropriate scenario planner.
///
/// - Expected Behavior:
/// - planningMode == .teamFirst with >= 2 teams ScenarioEPlanner
/// - followTeamId != nil ScenarioDPlanner
/// - selectedGames not empty ScenarioBPlanner
/// - startLocation AND endLocation != nil ScenarioCPlanner
/// - Otherwise ScenarioAPlanner (default)
///
/// Priority order: E > D > B > C > A (first matching wins)
enum ScenarioPlannerFactory {
/// Creates the appropriate planner based on the request inputs.
///
/// - Expected Behavior:
/// - planningMode == .teamFirst with >= 2 teams ScenarioEPlanner
/// - followTeamId set ScenarioDPlanner
/// - selectedGames not empty ScenarioBPlanner
/// - Both start and end locations ScenarioCPlanner
/// - Otherwise ScenarioAPlanner
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
print("🔍 ScenarioPlannerFactory: Selecting planner...")
print(" - planningMode: \(request.preferences.planningMode)")
print(" - selectedTeamIds.count: \(request.preferences.selectedTeamIds.count)")
print(" - followTeamId: \(request.preferences.followTeamId ?? "nil")")
print(" - selectedGames.count: \(request.selectedGames.count)")
print(" - startLocation: \(request.startLocation?.name ?? "nil")")
print(" - endLocation: \(request.endLocation?.name ?? "nil")")
// Scenario E: Team-First mode - user selects teams, finds optimal trip windows
if request.preferences.planningMode == .teamFirst &&
request.preferences.selectedTeamIds.count >= 2 {
print("🔍 ScenarioPlannerFactory: → ScenarioEPlanner (team-first)")
return ScenarioEPlanner()
}
// Scenario D fallback: Team-First with single team follow that team
if request.preferences.planningMode == .teamFirst &&
request.preferences.selectedTeamIds.count == 1 {
// Single team in teamFirst mode treat as follow-team
print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (team-first single team)")
return ScenarioDPlanner()
}
// Scenario D: User wants to follow a specific team
if request.preferences.followTeamId != nil {
print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (follow team)")
return ScenarioDPlanner()
}
// Scenario B: User selected specific games
if !request.selectedGames.isEmpty {
print("🔍 ScenarioPlannerFactory: → ScenarioBPlanner (selected games)")
return ScenarioBPlanner()
}
// Scenario C: User specified start and end locations
if request.startLocation != nil && request.endLocation != nil {
print("🔍 ScenarioPlannerFactory: → ScenarioCPlanner (start/end locations)")
return ScenarioCPlanner()
}
// Scenario A: Date range only (default)
print("🔍 ScenarioPlannerFactory: → ScenarioAPlanner (default/date range)")
return ScenarioAPlanner()
}
/// Classifies which scenario applies to this request.
///
/// - Expected Behavior:
/// - planningMode == .teamFirst with >= 2 teams .scenarioE
/// - followTeamId set .scenarioD
/// - selectedGames not empty .scenarioB
/// - Both start and end locations .scenarioC
/// - Otherwise .scenarioA
static func classify(_ request: PlanningRequest) -> PlanningScenario {
if request.preferences.planningMode == .teamFirst &&
request.preferences.selectedTeamIds.count >= 2 {
return .scenarioE
}
// Scenario D fallback: Team-First with single team follow that team
if request.preferences.planningMode == .teamFirst &&
request.preferences.selectedTeamIds.count == 1 {
return .scenarioD
}
if request.preferences.followTeamId != nil {
return .scenarioD
}
if !request.selectedGames.isEmpty {
return .scenarioB
}
if request.startLocation != nil && request.endLocation != nil {
return .scenarioC
}
return .scenarioA
}
}