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>
122 lines
4.9 KiB
Swift
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
|
|
}
|
|
}
|