diff --git a/AppStoreScreens.pxd b/AppStoreScreens.pxd index 7e82d71..4509c75 100644 Binary files a/AppStoreScreens.pxd and b/AppStoreScreens.pxd differ diff --git a/SportsTime/Core/Models/Domain/TripPreferences.swift b/SportsTime/Core/Models/Domain/TripPreferences.swift index d2d9a59..1ea9de1 100644 --- a/SportsTime/Core/Models/Domain/TripPreferences.swift +++ b/SportsTime/Core/Models/Domain/TripPreferences.swift @@ -325,7 +325,7 @@ struct TripPreferences: Codable, Hashable { var effectiveTripDuration: Int { if let duration = tripDuration { return duration } let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 7 - return max(1, days) + return max(1, days + 1) } /// Maximum trip duration for Team-First mode (2 days per selected team) diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 820c8d0..3659ed5 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -124,6 +124,9 @@ final class TripWizardViewModel { // Common requirements for all modes guard hasSetRoutePreference && hasSetRepeatCities else { return false } + // Date validation: endDate must not be before startDate for modes that use dates + if hasSetDates && endDate < startDate { return false } + switch mode { case .dateRange: return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty diff --git a/SportsTime/Planning/Engine/ItineraryBuilder.swift b/SportsTime/Planning/Engine/ItineraryBuilder.swift index e3a4e37..6cfea67 100644 --- a/SportsTime/Planning/Engine/ItineraryBuilder.swift +++ b/SportsTime/Planning/Engine/ItineraryBuilder.swift @@ -376,9 +376,14 @@ enum ItineraryBuilder { return true // No game = no constraint } - // Check if there's enough time between departure point and game start - // Departure assumed after previous day's activities (use departure date as baseline) - let earliestDeparture = fromStop.departureDate + // Account for game end time at fromStop — can't depart during a game + let typicalGameDuration: TimeInterval = 10800 // 3 hours + var earliestDeparture = fromStop.departureDate + if let fromGameStart = fromStop.firstGameStart { + let gameEnd = fromGameStart.addingTimeInterval(typicalGameDuration) + earliestDeparture = max(earliestDeparture, gameEnd) + } + let travelDuration = segment.durationSeconds let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration) let deadline = gameStart.addingTimeInterval(-bufferSeconds) diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index 559f7bc..0e64dec 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -316,16 +316,16 @@ final class ScenarioBPlanner: ScenarioPlanner { // First window: last selected game is on last day of window // Window end = lastGameDate + 1 day (to include the game) // Window start = end - duration days - let firstWindowEnd = Calendar.current.date( + guard let firstWindowEnd = Calendar.current.date( byAdding: .day, value: 1, to: lastGameDate - )! - let firstWindowStart = Calendar.current.date( + ) else { return [] } + guard let firstWindowStart = Calendar.current.date( byAdding: .day, value: -duration, to: firstWindowEnd - )! + ) else { return [] } // Last window: first selected game is on first day of window // Window start = firstGameDate @@ -334,21 +334,22 @@ final class ScenarioBPlanner: ScenarioPlanner { // Slide from first window to last window var currentStart = firstWindowStart while currentStart <= lastWindowStart { - let windowEnd = Calendar.current.date( + guard let windowEnd = Calendar.current.date( byAdding: .day, value: duration, to: currentStart - )! + ) else { break } let window = DateInterval(start: currentStart, end: windowEnd) dateRanges.append(window) // Slide forward one day - currentStart = Calendar.current.date( + guard let nextStart = Calendar.current.date( byAdding: .day, value: 1, to: currentStart - )! + ) else { break } + currentStart = nextStart } return dateRanges diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index 4ac48f8..6188f9b 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -445,7 +445,7 @@ final class ScenarioCPlanner: ScenarioPlanner { let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0 // Must be within day span - guard daysBetween < daySpan else { continue } + guard daysBetween <= daySpan else { continue } // Create date range (end date + 1 day to include the end game) let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift index f9fae80..9a7b29f 100644 --- a/SportsTime/Planning/Engine/ScenarioDPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -70,7 +70,8 @@ final class ScenarioDPlanner: ScenarioPlanner { // ────────────────────────────────────────────────────────────────── // Step 1: Validate team selection // ────────────────────────────────────────────────────────────────── - guard let teamId = request.preferences.followTeamId else { + guard let teamId = request.preferences.followTeamId + ?? request.preferences.selectedTeamIds.first else { return .failure( PlanningFailure( reason: .missingTeamSelection, @@ -261,7 +262,7 @@ final class ScenarioDPlanner: ScenarioPlanner { travelSegments: itinerary.travelSegments, totalDrivingHours: itinerary.totalDrivingHours, totalDistanceMiles: itinerary.totalDistanceMiles, - geographicRationale: "Follow Team: \(stops.count) games - \(cities)" + geographicRationale: "Follow Team: \(itinerary.stops.reduce(0) { $0 + $1.games.count }) games - \(cities)" ) itineraryOptions.append(option) } @@ -396,6 +397,7 @@ final class ScenarioDPlanner: ScenarioPlanner { ) let lastGameDate = sortedGames.last?.gameDate ?? Date() + let calendar = Calendar.current return ItineraryStop( city: city, @@ -403,7 +405,7 @@ final class ScenarioDPlanner: ScenarioPlanner { coordinate: coordinate, games: sortedGames.map { $0.id }, arrivalDate: sortedGames.first?.gameDate ?? Date(), - departureDate: lastGameDate, + departureDate: calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate.addingTimeInterval(86400), location: location, firstGameStart: sortedGames.first?.startTime ) diff --git a/SportsTime/Planning/Engine/ScenarioPlanner.swift b/SportsTime/Planning/Engine/ScenarioPlanner.swift index 307ae2e..6e5ab50 100644 --- a/SportsTime/Planning/Engine/ScenarioPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioPlanner.swift @@ -58,6 +58,14 @@ enum ScenarioPlannerFactory { 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)") @@ -94,6 +102,11 @@ enum ScenarioPlannerFactory { 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 } diff --git a/SportsTime/Planning/Models/PlanningModels.swift b/SportsTime/Planning/Models/PlanningModels.swift index bc666c8..9c30a4a 100644 --- a/SportsTime/Planning/Models/PlanningModels.swift +++ b/SportsTime/Planning/Models/PlanningModels.swift @@ -317,7 +317,7 @@ struct DrivingConstraints { init(from preferences: TripPreferences) { self.numberOfDrivers = max(1, preferences.numberOfDrivers) - self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0 + self.maxHoursPerDriverPerDay = max(1.0, preferences.maxDrivingHoursPerDriver ?? 8.0) } } @@ -472,7 +472,8 @@ extension ItineraryOption { ) restDays.append(restDay) } - currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: currentDay) else { break } + currentDay = nextDay } return restDays @@ -497,16 +498,17 @@ extension ItineraryOption { /// All dates covered by the itinerary. func allDates() -> [Date] { let calendar = Calendar.current - guard let firstStop = stops.first, - let lastStop = stops.last else { return [] } + guard let earliestArrival = stops.map(\.arrivalDate).min(), + let latestDeparture = stops.map(\.departureDate).max() else { return [] } var dates: [Date] = [] - var currentDate = calendar.startOfDay(for: firstStop.arrivalDate) - let endDate = calendar.startOfDay(for: lastStop.departureDate) + var currentDate = calendar.startOfDay(for: earliestArrival) + let endDate = calendar.startOfDay(for: latestDeparture) while currentDate <= endDate { dates.append(currentDate) - currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate + guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break } + currentDate = nextDate } return dates @@ -548,7 +550,7 @@ struct PlanningRequest { /// Note: End date is extended to end-of-day to include all games on the last day, /// since DateInterval.contains() uses exclusive end boundary. var dateRange: DateInterval? { - guard preferences.endDate > preferences.startDate else { return nil } + guard preferences.endDate >= preferences.startDate else { return nil } // Extend end date to end of day (23:59:59) to include games on the last day let calendar = Calendar.current let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate) diff --git a/SportsTimeTests/Domain/TripPreferencesTests.swift b/SportsTimeTests/Domain/TripPreferencesTests.swift index e300b5f..a0d180e 100644 --- a/SportsTimeTests/Domain/TripPreferencesTests.swift +++ b/SportsTimeTests/Domain/TripPreferencesTests.swift @@ -70,7 +70,7 @@ struct TripPreferencesTests { tripDuration: nil ) - #expect(prefs.effectiveTripDuration == 7) // 7 days between 15th and 22nd + #expect(prefs.effectiveTripDuration == 8) // 8 days inclusive (15th through 22nd) } @Test("effectiveTripDuration: minimum is 1") diff --git a/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift new file mode 100644 index 0000000..a6cab27 --- /dev/null +++ b/SportsTimeTests/Planning/PlanningPipelineBugRegressionTests.swift @@ -0,0 +1,767 @@ +// +// PlanningPipelineBugRegressionTests.swift +// SportsTimeTests +// +// Regression tests for bugs found in the planning pipeline code review. +// Each test is numbered to match the bug report (Bug #1 through #16). +// + +import Testing +import Foundation +import CoreLocation +@testable import SportsTime + +// MARK: - Bug #1: teamFirst with 1 team falls through to ScenarioA + +@Suite("Bug #1: ScenarioPlannerFactory teamFirst single team") +struct Bug1_TeamFirstSingleTeamTests { + + @Test("teamFirst with 1 team should not fall through to ScenarioA") + func teamFirst_singleTeam_shouldNotFallThroughToScenarioA() { + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + travelMode: .drive, + startDate: Date(), + endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date())!, + leisureLevel: .moderate, + routePreference: .balanced, + selectedTeamIds: ["team_mlb_boston"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [], + teams: [:], + stadiums: [:] + ) + + let scenario = ScenarioPlannerFactory.classify(request) + // With 1 team in teamFirst, should dispatch to ScenarioD (follow team) or ScenarioE, NOT ScenarioA + #expect(scenario != .scenarioA, "1-team teamFirst should not fall through to ScenarioA (date range)") + } + + @Test("teamFirst with 2 teams dispatches to ScenarioE") + func teamFirst_twoTeams_dispatchesToScenarioE() { + let prefs = TripPreferences( + planningMode: .teamFirst, + sports: [.mlb], + travelMode: .drive, + startDate: Date(), + endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date())!, + leisureLevel: .moderate, + routePreference: .balanced, + selectedTeamIds: ["team_mlb_boston", "team_mlb_new_york"] + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [], + teams: [:], + stadiums: [:] + ) + + let scenario = ScenarioPlannerFactory.classify(request) + #expect(scenario == .scenarioE) + } +} + +// MARK: - Bug #2: Infinite loop in calculateRestDays / allDates + +@Suite("Bug #2: Infinite loop safety in date iteration") +struct Bug2_InfiniteLoopTests { + + @Test("calculateRestDays does not hang for normal multi-day stop") + func calculateRestDays_normalMultiDay_terminates() { + let calendar = Calendar.current + let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12) + let departure = TestFixtures.date(year: 2026, month: 6, day: 14, hour: 12) + + let stop = ItineraryStop( + city: "Boston", + state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["game_1"], + arrivalDate: arrival, + departureDate: departure, + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: arrival + ) + + let option = ItineraryOption( + rank: 1, + stops: [stop], + travelSegments: [], + totalDrivingHours: 0, + totalDistanceMiles: 0, + geographicRationale: "Test" + ) + + // Should complete without hanging. Rest days = days between arrival and departure + // (excluding both endpoints) = Jun 11, 12, 13 = 3 rest days + let timeline = option.generateTimeline() + let restItems = timeline.filter { $0.isRest } + #expect(restItems.count == 3) + } + + @Test("allDates does not hang for normal date range") + func allDates_normalRange_terminates() { + let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12) + let departure = TestFixtures.date(year: 2026, month: 6, day: 13, hour: 12) + + let stop = ItineraryStop( + city: "Boston", + state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["game_1"], + arrivalDate: arrival, + departureDate: departure, + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: arrival + ) + + let option = ItineraryOption( + rank: 1, + stops: [stop], + travelSegments: [], + totalDrivingHours: 0, + totalDistanceMiles: 0, + geographicRationale: "Test" + ) + + // Should return 4 dates: Jun 10, 11, 12, 13 + let dates = option.allDates() + #expect(dates.count == 4) + } +} + +// MARK: - Bug #3: Same-day trip rejection + +@Suite("Bug #3: Same-day trip date range") +struct Bug3_SameDayTripTests { + + @Test("dateRange should be valid when startDate equals endDate") + func dateRange_sameDayTrip_shouldBeValid() { + let today = TestFixtures.date(year: 2026, month: 7, day: 4, hour: 8) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: today, + endDate: today, + leisureLevel: .moderate, + routePreference: .balanced + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [], + teams: [:], + stadiums: [:] + ) + + #expect(request.dateRange != nil, "Same-day trip should produce a valid dateRange, not nil") + } +} + +// MARK: - Bug #4: ScenarioD rationale shows stops.count not game count + +@Suite("Bug #4: ScenarioD rationale game count") +struct Bug4_ScenarioDRationaleTests { + + @Test("geographicRationale should show game count not stop count") + func rationale_shouldShowGameCount() { + // Create 3 games across 2 cities (2 in Boston, 1 in NYC) + let date1 = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19) + let date2 = TestFixtures.date(year: 2026, month: 7, day: 2, hour: 19) + let date3 = TestFixtures.date(year: 2026, month: 7, day: 4, hour: 19) + + let bostonStadium = TestFixtures.stadium(city: "Boston") + let nycStadium = TestFixtures.stadium(city: "New York") + + let bostonTeamId = "team_mlb_boston" + let games = [ + TestFixtures.game(id: "g1", city: "Boston", dateTime: date1, homeTeamId: bostonTeamId, stadiumId: bostonStadium.id), + TestFixtures.game(id: "g2", city: "Boston", dateTime: date2, homeTeamId: bostonTeamId, stadiumId: bostonStadium.id), + TestFixtures.game(id: "g3", city: "New York", dateTime: date3, awayTeamId: bostonTeamId, stadiumId: nycStadium.id), + ] + + let prefs = TripPreferences( + planningMode: .followTeam, + sports: [.mlb], + travelMode: .drive, + startDate: date1.addingTimeInterval(-86400), + endDate: date3.addingTimeInterval(86400), + leisureLevel: .moderate, + routePreference: .balanced, + followTeamId: bostonTeamId + ) + + let stadiums: [String: Stadium] = [ + bostonStadium.id: bostonStadium, + nycStadium.id: nycStadium, + ] + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: [:], + stadiums: stadiums + ) + + let planner = ScenarioDPlanner() + let result = planner.plan(request: request) + + if case .success(let options) = result { + // Bug #4: rationale was using stops.count instead of actual game count. + // Verify that for each option, the game count in the rationale matches + // the actual total games across stops. + for option in options { + let actualGameCount = option.stops.reduce(0) { $0 + $1.games.count } + let rationale = option.geographicRationale + #expect(rationale.contains("\(actualGameCount) games"), + "Rationale game count should match actual games (\(actualGameCount)). Got: \(rationale)") + } + } + // If planning fails, that's OK — this test focuses on rationale text when it succeeds + } +} + +// MARK: - Bug #5: ScenarioD departureDate not advanced + +@Suite("Bug #5: ScenarioD departure date") +struct Bug5_ScenarioDDepartureDateTests { + + @Test("stop departureDate should be after last game, not same day") + func departureDate_shouldBeAfterLastGame() { + let gameDate = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19) + let calendar = Calendar.current + + let bostonStadium = TestFixtures.stadium(city: "Boston") + let game = TestFixtures.game(id: "g1", city: "Boston", dateTime: gameDate, stadiumId: bostonStadium.id) + + let prefs = TripPreferences( + planningMode: .followTeam, + sports: [.mlb], + travelMode: .drive, + startDate: gameDate.addingTimeInterval(-86400), + endDate: gameDate.addingTimeInterval(86400 * 3), + leisureLevel: .moderate, + routePreference: .balanced, + followTeamId: game.homeTeamId + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [game], + teams: [:], + stadiums: [bostonStadium.id: bostonStadium] + ) + + let planner = ScenarioDPlanner() + let result = planner.plan(request: request) + + if case .success(let options) = result, let option = options.first { + // Find the game stop (not the home start/end waypoints) + let gameStops = option.stops.filter { $0.hasGames } + if let gameStop = gameStops.first { + let gameDayStart = calendar.startOfDay(for: gameDate) + let departureDayStart = calendar.startOfDay(for: gameStop.departureDate) + #expect(departureDayStart > gameDayStart, + "Departure should be after game day, not same day. Game: \(gameDayStart), Departure: \(departureDayStart)") + } + } + } +} + +// MARK: - Bug #6: ScenarioC date range off-by-one + +@Suite("Bug #6: ScenarioC date range boundary") +struct Bug6_ScenarioCDateRangeTests { + + @Test("games spanning exactly daySpan should be included") + func gamesSpanningExactDaySpan_shouldBeIncluded() { + // If daySpan is 7, games exactly 7 days apart should be valid + let calendar = Calendar.current + let startDate = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19) + let endDate = calendar.date(byAdding: .day, value: 7, to: startDate)! + + let startStadium = TestFixtures.stadium(city: "New York") + let endStadium = TestFixtures.stadium(city: "Chicago") + + let startGame = TestFixtures.game(id: "g1", city: "New York", dateTime: startDate, stadiumId: startStadium.id) + let endGame = TestFixtures.game(id: "g2", city: "Chicago", dateTime: endDate, stadiumId: endStadium.id) + + let prefs = TripPreferences( + planningMode: .locations, + startLocation: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), + endLocation: LocationInput(name: "Chicago", coordinate: TestFixtures.coordinates["Chicago"]), + sports: [.mlb], + travelMode: .drive, + startDate: startDate.addingTimeInterval(-86400), + endDate: endDate.addingTimeInterval(86400), + tripDuration: 7, + leisureLevel: .moderate, + routePreference: .balanced + ) + + let stadiums: [String: Stadium] = [ + startStadium.id: startStadium, + endStadium.id: endStadium, + ] + + let request = PlanningRequest( + preferences: prefs, + availableGames: [startGame, endGame], + teams: [:], + stadiums: stadiums + ) + + let planner = ScenarioCPlanner() + let result = planner.plan(request: request) + + // Should find at least one option — games exactly span the trip duration + if case .failure(let failure) = result { + let reason = failure.reason + #expect(reason != PlanningFailure.FailureReason.noGamesInRange, + "Games spanning exactly daySpan should not be excluded. Failure: \(failure.message)") + } + } +} + +// MARK: - Bug #7: DrivingConstraints missing clamp + +@Suite("Bug #7: DrivingConstraints init(from:) clamp") +struct Bug7_DrivingConstraintsClampTests { + + @Test("init(from:) should clamp maxDrivingHoursPerDriver to minimum 1.0") + func initFromPreferences_clampsMaxHours() { + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: Date(), + endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date())!, + leisureLevel: .moderate, + routePreference: .balanced, + maxDrivingHoursPerDriver: 0.5 + ) + + let constraints = DrivingConstraints(from: prefs) + #expect(constraints.maxHoursPerDriverPerDay >= 1.0, + "maxHoursPerDriverPerDay should be clamped to at least 1.0, got \(constraints.maxHoursPerDriverPerDay)") + } + + @Test("standard init clamps correctly") + func standardInit_clampsMaxHours() { + let constraints = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 0.5) + #expect(constraints.maxHoursPerDriverPerDay >= 1.0) + } + + @Test("nil maxDrivingHoursPerDriver defaults to 8.0") + func nilMaxHours_defaultsTo8() { + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: Date(), + endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date())!, + leisureLevel: .moderate, + routePreference: .balanced, + maxDrivingHoursPerDriver: nil + ) + + let constraints = DrivingConstraints(from: prefs) + #expect(constraints.maxHoursPerDriverPerDay == 8.0) + } +} + +// MARK: - Bug #8: effectiveTripDuration off by one + +@Suite("Bug #8: effectiveTripDuration off-by-one") +struct Bug8_EffectiveTripDurationTests { + + @Test("3-day trip (May 1-3) should return 3, not 2") + func threeDayTrip_returns3() { + let start = TestFixtures.date(year: 2026, month: 5, day: 1, hour: 8) + let end = TestFixtures.date(year: 2026, month: 5, day: 3, hour: 8) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: start, + endDate: end, + leisureLevel: .moderate, + routePreference: .balanced + ) + + #expect(prefs.effectiveTripDuration == 3, + "May 1-3 is 3 days inclusive, got \(prefs.effectiveTripDuration)") + } + + @Test("1-day trip (same day) should return 1") + func sameDayTrip_returns1() { + let date = TestFixtures.date(year: 2026, month: 5, day: 1, hour: 8) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: date, + endDate: date, + leisureLevel: .moderate, + routePreference: .balanced + ) + + #expect(prefs.effectiveTripDuration == 1, + "Same-day trip should be 1 day, got \(prefs.effectiveTripDuration)") + } + + @Test("7-day trip returns 7") + func sevenDayTrip_returns7() { + let start = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 8) + let end = TestFixtures.date(year: 2026, month: 6, day: 7, hour: 8) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: start, + endDate: end, + leisureLevel: .moderate, + routePreference: .balanced + ) + + #expect(prefs.effectiveTripDuration == 7, + "Jun 1-7 is 7 days inclusive, got \(prefs.effectiveTripDuration)") + } +} + +// MARK: - Bug #9: TripWizardViewModel missing date validation +// Note: ViewModel tests require @MainActor — tested via TripPreferences validation + +@Suite("Bug #9: Date validation") +struct Bug9_DateValidationTests { + + @Test("PlanningRequest.dateRange returns nil for reversed dates") + func reversedDates_returnsNil() { + let start = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 8) + let end = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 8) + + let prefs = TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + travelMode: .drive, + startDate: start, + endDate: end, + leisureLevel: .moderate, + routePreference: .balanced + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [], + teams: [:], + stadiums: [:] + ) + + // Reversed dates should return nil dateRange (this is expected behavior) + #expect(request.dateRange == nil) + } +} + +// MARK: - Bug #10: allDates assumes sorted stops + +@Suite("Bug #10: allDates with unsorted stops") +struct Bug10_AllDatesUnsortedTests { + + @Test("allDates returns correct range even with properly ordered stops") + func allDates_sortedStops_correctRange() { + let arrival1 = TestFixtures.date(year: 2026, month: 6, day: 1, hour: 12) + let departure1 = TestFixtures.date(year: 2026, month: 6, day: 3, hour: 12) + let arrival2 = TestFixtures.date(year: 2026, month: 6, day: 3, hour: 18) + let departure2 = TestFixtures.date(year: 2026, month: 6, day: 5, hour: 12) + + let stop1 = ItineraryStop( + city: "Boston", state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["g1"], arrivalDate: arrival1, departureDate: departure1, + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: arrival1 + ) + let stop2 = ItineraryStop( + city: "New York", state: "NY", + coordinate: TestFixtures.coordinates["New York"], + games: ["g2"], arrivalDate: arrival2, departureDate: departure2, + location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), + firstGameStart: arrival2 + ) + + let option = ItineraryOption( + rank: 1, stops: [stop1, stop2], travelSegments: [], + totalDrivingHours: 3, totalDistanceMiles: 200, + geographicRationale: "Test" + ) + + let dates = option.allDates() + // Jun 1-5 = 5 dates + #expect(dates.count == 5) + } +} + +// MARK: - Bug #11: Moderate leisure sorting degenerates for zero driving + +@Suite("Bug #11: sortByLeisure with zero driving") +struct Bug11_SortByLeisureTests { + + @Test("moderate sorting differentiates zero-driving options meaningfully") + func moderateSorting_zeroDriving_sortsByGameCount() { + let stop1 = ItineraryStop( + city: "Boston", state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["g1", "g2", "g3"], + arrivalDate: Date(), departureDate: Date().addingTimeInterval(86400), + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: Date() + ) + + let stop2 = ItineraryStop( + city: "Boston", state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["g4"], + arrivalDate: Date(), departureDate: Date().addingTimeInterval(86400), + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: Date() + ) + + let option3Games = ItineraryOption( + rank: 1, stops: [stop1], travelSegments: [], + totalDrivingHours: 0, totalDistanceMiles: 0, + geographicRationale: "3 games" + ) + + let option1Game = ItineraryOption( + rank: 2, stops: [stop2], travelSegments: [], + totalDrivingHours: 0, totalDistanceMiles: 0, + geographicRationale: "1 game" + ) + + let sorted = ItineraryOption.sortByLeisure([option1Game, option3Games], leisureLevel: .moderate) + // With zero driving, moderate should still rank options + // (degenerates to game count, which is documented behavior — test confirms it works) + #expect(sorted.first?.totalGames == 3, "With zero driving, more games should rank first in moderate mode") + } +} + +// MARK: - Bug #12: ItineraryBuilder validator ignores game end time + +@Suite("Bug #12: arrivalBeforeGameStart validator") +struct Bug12_ValidatorGameEndTimeTests { + + @Test("validator checks arrival feasibility with buffer") + func validator_checksArrivalBuffer() { + // Use deterministic dates to avoid time-of-day sensitivity + let calendar = Calendar.current + let today = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 14) // 2pm today + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today)! + let tomorrowMorning = TestFixtures.date(year: 2026, month: 7, day: 11, hour: 8) // 8am departure + let gameTimeTomorrow = TestFixtures.date(year: 2026, month: 7, day: 11, hour: 19) // 7pm game + + let fromStop = ItineraryStop( + city: "Boston", state: "MA", + coordinate: TestFixtures.coordinates["Boston"], + games: ["g1"], + arrivalDate: today, departureDate: tomorrowMorning, + location: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + firstGameStart: today // Game at fromStop starts at 2pm today, ends ~5pm + ) + + let toStop = ItineraryStop( + city: "New York", state: "NY", + coordinate: TestFixtures.coordinates["New York"], + games: ["g2"], + arrivalDate: tomorrow, departureDate: tomorrow.addingTimeInterval(86400), + location: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), + firstGameStart: gameTimeTomorrow + ) + + let shortSegment = TravelSegment( + fromLocation: LocationInput(name: "Boston", coordinate: TestFixtures.coordinates["Boston"]), + toLocation: LocationInput(name: "New York", coordinate: TestFixtures.coordinates["New York"]), + travelMode: .drive, + distanceMeters: 300000, + durationSeconds: 3600 * 3 // 3 hours + ) + + let validator = ItineraryBuilder.arrivalBeforeGameStart(bufferSeconds: 3600) + let result = validator(shortSegment, fromStop, toStop) + // Depart 8am + 3h travel = arrive 11am, game at 7pm - 1h buffer = 6pm deadline. 11am < 6pm → pass + #expect(result == true, "3-hour drive with ample time should pass validation") + } +} + +// MARK: - Bug #13: Silent game exclusion with missing stadium + +@Suite("Bug #13: Missing stadium in region filter") +struct Bug13_MissingStadiumTests { + + @Test("games with missing stadiums excluded when regions active") + func missingStadium_excludedWithRegionFilter() { + let gameDate = TestFixtures.date(year: 2026, month: 7, day: 5, hour: 19) + let game = TestFixtures.game( + id: "orphan_game", + city: "New York", + dateTime: gameDate, + stadiumId: "stadium_that_does_not_exist" + ) + + let prefs = TestFixtures.preferences( + startDate: gameDate.addingTimeInterval(-86400), + endDate: gameDate.addingTimeInterval(86400), + regions: [.east] // Region filter is active + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [game], + teams: [:], + stadiums: [:] // No stadiums — game's stadium is missing + ) + + let planner = ScenarioAPlanner() + let result = planner.plan(request: request) + + // Currently: silently excluded → noGamesInRange. + // This test documents the current behavior (missing stadiums are excluded). + if case .failure(let failure) = result { + #expect(failure.reason == .noGamesInRange) + } + } +} + +// MARK: - Bug #14: Drag drop feedback (UI test — documented only) +// This is a UI interaction bug that cannot be unit tested. +// Documenting the expected behavior here. + +@Suite("Bug #14: Drag drop feedback") +struct Bug14_DragDropTests { + + @Test("documented: drag state should not be cleared before validation") + func documented_dragStateShouldPersistDuringValidation() { + // This bug is in TripDetailView.swift:1508-1525 (UI layer). + // Drag state is cleared synchronously before async validation runs. + // If validation fails, no visual feedback is shown. + // Fix: Move drag state clearing AFTER validation succeeds. + #expect(true, "UI bug documented — drag state should persist during validation") + } +} + +// MARK: - Bug #15: ScenarioB force unwraps on date arithmetic + +@Suite("Bug #15: ScenarioB date arithmetic safety") +struct Bug15_DateArithmeticTests { + + @Test("generateDateRanges handles normal date ranges safely") + func generateDateRanges_normalDates_doesNotCrash() { + let start = TestFixtures.date(year: 2026, month: 7, day: 1, hour: 19) + let end = TestFixtures.date(year: 2026, month: 7, day: 10, hour: 19) + + let startStadium = TestFixtures.stadium(city: "Boston") + let endStadium = TestFixtures.stadium(city: "New York") + + let games = [ + TestFixtures.game(id: "g1", city: "Boston", dateTime: start, stadiumId: startStadium.id), + TestFixtures.game(id: "g2", city: "New York", dateTime: end, stadiumId: endStadium.id), + ] + + let prefs = TripPreferences( + planningMode: .gameFirst, + sports: [.mlb], + mustSeeGameIds: Set(games.map { $0.id }), + travelMode: .drive, + startDate: start.addingTimeInterval(-86400 * 2), + endDate: end.addingTimeInterval(86400 * 2), + leisureLevel: .moderate, + routePreference: .balanced + ) + + let stadiums: [String: Stadium] = [ + startStadium.id: startStadium, + endStadium.id: endStadium, + ] + + let request = PlanningRequest( + preferences: prefs, + availableGames: games, + teams: [:], + stadiums: stadiums + ) + + let planner = ScenarioBPlanner() + // Should not crash — just verifying safety + let _ = planner.plan(request: request) + } +} + +// MARK: - Bug #16: Negative sortOrder accumulation + +@Suite("Bug #16: Sort order accumulation") +struct Bug16_SortOrderTests { + + @Test("documented: repeated before-games moves should use midpoint not subtraction") + func documented_sortOrderShouldNotGoExtremelyNegative() { + // This bug is in ItineraryReorderingLogic.swift:420-428. + // Each "move before first item" subtracts 1.0 instead of using midpoint. + // After many moves, sortOrder becomes -10, -20, etc. + // Fix: Use midpoint (n/2.0) instead of subtraction (n-1.0). + #expect(true, "Documented: sortOrder should use midpoint insertion") + } +} + +// MARK: - Cross-cutting: TravelEstimator consistency + +@Suite("TravelEstimator consistency") +struct TravelEstimatorConsistencyTests { + + @Test("ItineraryStop and LocationInput estimates produce consistent distances") + func bothOverloads_produceConsistentDistances() { + let bostonCoord = TestFixtures.coordinates["Boston"]! + let nycCoord = TestFixtures.coordinates["New York"]! + + let constraints = DrivingConstraints.default + + // ItineraryStop overload + let fromStop = ItineraryStop( + city: "Boston", state: "MA", coordinate: bostonCoord, + games: [], arrivalDate: Date(), departureDate: Date(), + location: LocationInput(name: "Boston", coordinate: bostonCoord), + firstGameStart: nil + ) + let toStop = ItineraryStop( + city: "New York", state: "NY", coordinate: nycCoord, + games: [], arrivalDate: Date(), departureDate: Date(), + location: LocationInput(name: "New York", coordinate: nycCoord), + firstGameStart: nil + ) + + let stopResult = TravelEstimator.estimate(from: fromStop, to: toStop, constraints: constraints) + + // LocationInput overload + let fromLoc = LocationInput(name: "Boston", coordinate: bostonCoord) + let toLoc = LocationInput(name: "New York", coordinate: nycCoord) + + let locResult = TravelEstimator.estimate(from: fromLoc, to: toLoc, constraints: constraints) + + guard let s = stopResult, let l = locResult else { + Issue.record("Both estimates should return non-nil results") + return + } + + // Distances should be within 1% of each other + let ratio = s.distanceMeters / l.distanceMeters + #expect(abs(ratio - 1.0) < 0.01, + "Distance mismatch: stop=\(s.distanceMeters)m vs location=\(l.distanceMeters)m, ratio=\(ratio)") + } +} diff --git a/SportsTimeTests/Planning/TripPlanningEngineTests.swift b/SportsTimeTests/Planning/TripPlanningEngineTests.swift index 91470f3..1abf921 100644 --- a/SportsTimeTests/Planning/TripPlanningEngineTests.swift +++ b/SportsTimeTests/Planning/TripPlanningEngineTests.swift @@ -122,7 +122,7 @@ struct TripPlanningEngineTests { endDate: endDate, tripDuration: nil ) - #expect(prefs.effectiveTripDuration == 7) + #expect(prefs.effectiveTripDuration == 8) // 8 days inclusive (15th through 22nd) } // MARK: - Invariant Tests diff --git a/marketing-videos/public/iphone.png b/marketing-videos/public/iphone.png new file mode 100644 index 0000000..bef9811 Binary files /dev/null and b/marketing-videos/public/iphone.png differ diff --git a/marketing-videos/src/theme.ts b/marketing-videos/src/theme.ts index 9e0d2d5..c8b30f0 100644 --- a/marketing-videos/src/theme.ts +++ b/marketing-videos/src/theme.ts @@ -35,6 +35,6 @@ export const SCENES = { export const TRANSITION_DURATION = 9; export const FPS = 30; -export const WIDTH = 1080; +export const WIDTH = 886; export const HEIGHT = 1920; export const TOTAL_DURATION = 450;