From 94bb68d4318715778819dc9eefa1f1035ac43821 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 21:13:45 -0600 Subject: [PATCH] fix(wizard): improve UX with step reordering and UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder wizard steps: dates before sports (enables availability check) - Add contentShape(Rectangle()) for full tap targets on all cards - Fix route preference showing preselected value - Fix sport cards having inconsistent heights - Speed up step reveal animation (0.3s → 0.15s) - Add debounced scroll delay to avoid interrupting selection Co-Authored-By: Claude Opus 4.5 --- .../Trip/ViewModels/TripWizardViewModel.swift | 8 +- .../Trip/Views/Wizard/Steps/DatesStep.swift | 70 ++---- .../Views/Wizard/Steps/PlanningModeStep.swift | 7 +- .../Trip/Views/Wizard/Steps/RegionsStep.swift | 64 +---- .../Views/Wizard/Steps/RepeatCitiesStep.swift | 1 + .../Wizard/Steps/RoutePreferenceStep.swift | 3 +- .../Trip/Views/Wizard/Steps/SportsStep.swift | 11 +- .../Trip/Views/Wizard/TripWizardView.swift | 123 ++++++---- .../Trip/TripWizardViewModelTests.swift | 223 ++++++++++++++++++ 9 files changed, 348 insertions(+), 162 deletions(-) diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 533b43a..2eddeec 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -62,16 +62,16 @@ final class TripWizardViewModel { var isPlanningModeStepVisible: Bool { true } - var isSportsStepVisible: Bool { + var isDatesStepVisible: Bool { planningMode != nil } - var isDatesStepVisible: Bool { - isSportsStepVisible && !selectedSports.isEmpty + var isSportsStepVisible: Bool { + isDatesStepVisible && hasSetDates } var isRegionsStepVisible: Bool { - isDatesStepVisible && hasSetDates + isSportsStepVisible && !selectedSports.isEmpty } var isRoutePreferenceStepVisible: Bool { diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift index 9a80ebb..bbaf458 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift @@ -2,7 +2,7 @@ // DatesStep.swift // SportsTime // -// Step 3 of the trip wizard - select travel dates. +// Step 2 of the trip wizard - select travel dates. // import SwiftUI @@ -18,49 +18,22 @@ struct DatesStep: View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "When would you like to travel?", - subtitle: "Pick your trip dates" + subtitle: "Tap to select start and end dates" ) - VStack(spacing: Theme.Spacing.md) { - DatePicker( - "Start Date", - selection: $startDate, - in: Date()..., - displayedComponents: .date - ) - .datePickerStyle(.compact) - .onChange(of: startDate) { _, newValue in - // Ensure end date is after start date - if endDate < newValue { - endDate = newValue.addingTimeInterval(86400) - } - hasSetDates = true - onDatesChanged() - } - - DatePicker( - "End Date", - selection: $endDate, - in: startDate..., - displayedComponents: .date - ) - .datePickerStyle(.compact) - .onChange(of: endDate) { _, _ in - hasSetDates = true - onDatesChanged() - } + DateRangePicker( + startDate: $startDate, + endDate: $endDate + ) + .onChange(of: startDate) { _, _ in + // Only mark as complete when user has selected both dates (end > start) + updateHasSetDates() + onDatesChanged() } - .padding(Theme.Spacing.sm) - .background(Theme.cardBackgroundElevated(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - - // Trip duration indicator - HStack { - Image(systemName: "calendar.badge.clock") - .foregroundStyle(Theme.textMuted(colorScheme)) - Text(durationText) - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) + .onChange(of: endDate) { _, _ in + // Only mark as complete when user has selected both dates (end > start) + updateHasSetDates() + onDatesChanged() } } .padding(Theme.Spacing.lg) @@ -72,15 +45,12 @@ struct DatesStep: View { } } - private var durationText: String { - let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0 - if days == 0 { - return "Same day trip" - } else if days == 1 { - return "1 day trip" - } else { - return "\(days) day trip" - } + private func updateHasSetDates() { + // User must select both start and end dates (end date must be after start) + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: startDate) + let endDay = calendar.startOfDay(for: endDate) + hasSetDates = endDay > startDay } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift index 51d74ad..14c2abf 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift @@ -20,7 +20,7 @@ struct PlanningModeStep: View { VStack(spacing: Theme.Spacing.sm) { ForEach(PlanningMode.allCases) { mode in - PlanningModeCard( + WizardModeCard( mode: mode, isSelected: selection == mode, onTap: { selection = mode } @@ -38,9 +38,9 @@ struct PlanningModeStep: View { } } -// MARK: - Planning Mode Card +// MARK: - Wizard Mode Card -private struct PlanningModeCard: View { +private struct WizardModeCard: View { @Environment(\.colorScheme) private var colorScheme let mode: PlanningMode let isSelected: Bool @@ -74,6 +74,7 @@ private struct PlanningModeCard: View { .padding(Theme.Spacing.md) .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .contentShape(Rectangle()) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift index 315161a..f65bc92 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift @@ -11,37 +11,19 @@ struct RegionsStep: View { @Environment(\.colorScheme) private var colorScheme @Binding var selectedRegions: Set - private let columns = [ - GridItem(.flexible()), - GridItem(.flexible()) - ] - var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Where do you want to go?", - subtitle: "Select one or more regions" + subtitle: "Tap the map to select regions" ) - LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) { - ForEach(Region.allCases.filter { $0 != .crossCountry }) { region in - RegionCard( - region: region, - isSelected: selectedRegions.contains(region), - onTap: { toggleRegion(region) } - ) + RegionMapSelector( + selectedRegions: $selectedRegions, + onToggle: { region in + toggleRegion(region) } - } - - if !selectedRegions.isEmpty { - HStack { - Image(systemName: "mappin.circle.fill") - .foregroundStyle(Theme.warmOrange) - Text("\(selectedRegions.count) region\(selectedRegions.count == 1 ? "" : "s") selected") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - } + ) } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) @@ -61,40 +43,6 @@ struct RegionsStep: View { } } -// MARK: - Region Card - -private struct RegionCard: View { - @Environment(\.colorScheme) private var colorScheme - let region: Region - let isSelected: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(spacing: Theme.Spacing.xs) { - Image(systemName: region.iconName) - .font(.title) - .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) - - Text(region.shortName) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textPrimary(colorScheme)) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, Theme.Spacing.md) - .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - .overlay( - RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) - ) - } - .buttonStyle(.plain) - } -} - // MARK: - Preview #Preview { diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift index 4e0df5b..f2ad5e1 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift @@ -77,6 +77,7 @@ private struct OptionButton: View { .padding(Theme.Spacing.md) .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .contentShape(Rectangle()) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift index dc985c0..d6f0cac 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift @@ -23,7 +23,7 @@ struct RoutePreferenceStep: View { ForEach(RoutePreference.allCases) { preference in RoutePreferenceCard( preference: preference, - isSelected: routePreference == preference, + isSelected: hasSetRoutePreference && routePreference == preference, onTap: { routePreference = preference hasSetRoutePreference = true @@ -78,6 +78,7 @@ private struct RoutePreferenceCard: View { .padding(Theme.Spacing.md) .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .contentShape(Rectangle()) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift index 4f28619..14fb3bd 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift @@ -2,7 +2,7 @@ // SportsStep.swift // SportsTime // -// Step 2 of the trip wizard - select sports leagues. +// Step 3 of the trip wizard - select sports leagues. // import SwiftUI @@ -92,16 +92,15 @@ private struct SportCard: View { .fontWeight(.medium) .foregroundStyle(cardColor) - if !isAvailable { - Text("No games") - .font(.caption2) - .foregroundStyle(Theme.textMuted(colorScheme)) - } + Text(isAvailable ? " " : "No games") + .font(.caption2) + .foregroundStyle(Theme.textMuted(colorScheme)) } .frame(maxWidth: .infinity) .padding(.vertical, Theme.Spacing.md) .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .contentShape(Rectangle()) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(borderColor, lineWidth: isSelected ? 2 : 1) diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index 40edcf4..b305c00 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -16,6 +16,8 @@ struct TripWizardView: View { @State private var planningError: String? @State private var showError = false + private let planningEngine = TripPlanningEngine() + var body: some View { NavigationStack { ScrollViewReader { proxy in @@ -25,19 +27,7 @@ struct TripWizardView: View { PlanningModeStep(selection: $viewModel.planningMode) .id("planningMode") - // Step 2: Sports (after mode selected) - if viewModel.isSportsStepVisible { - SportsStep( - selectedSports: $viewModel.selectedSports, - sportAvailability: viewModel.sportAvailability, - isLoading: viewModel.isLoadingSportAvailability, - canSelectSport: viewModel.canSelectSport - ) - .id("sports") - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - - // Step 3: Dates (after sport selected) + // Step 2: Dates (after mode selected) if viewModel.isDatesStepVisible { DatesStep( startDate: $viewModel.startDate, @@ -53,6 +43,18 @@ struct TripWizardView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } + // Step 3: Sports (after dates set) + if viewModel.isSportsStepVisible { + SportsStep( + selectedSports: $viewModel.selectedSports, + sportAvailability: viewModel.sportAvailability, + isLoading: viewModel.isLoadingSportAvailability, + canSelectSport: viewModel.canSelectSport + ) + .id("sports") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + // Step 4: Regions (after dates set) if viewModel.isRegionsStepVisible { RegionsStep(selectedRegions: $viewModel.selectedRegions) @@ -106,12 +108,15 @@ struct TripWizardView: View { } } .padding(Theme.Spacing.md) - .animation(.easeInOut(duration: 0.3), value: viewModel.revealState) + .animation(.easeInOut(duration: 0.15), value: viewModel.revealState) } - .onChange(of: viewModel.revealState) { _, _ in - // Auto-scroll to newly revealed section - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation { + .onChange(of: viewModel.revealState) { _, newState in + // Auto-scroll to newly revealed section after a delay + // to avoid interrupting user interactions + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + // Only scroll if state hasn't changed (user stopped interacting) + guard viewModel.revealState == newState else { return } + withAnimation(.easeInOut(duration: 0.25)) { scrollToLatestStep(proxy: proxy) } } @@ -130,7 +135,9 @@ struct TripWizardView: View { options: tripOptions, games: [:], preferences: buildPreferences(), - convertToTrip: { _ in nil } + convertToTrip: { option in + convertOptionToTrip(option) + } ) } .alert("Planning Error", isPresented: $showError) { @@ -154,10 +161,10 @@ struct TripWizardView: View { proxy.scrollTo("routePreference", anchor: .top) } else if viewModel.isRegionsStepVisible { proxy.scrollTo("regions", anchor: .top) - } else if viewModel.isDatesStepVisible { - proxy.scrollTo("dates", anchor: .top) } else if viewModel.isSportsStepVisible { proxy.scrollTo("sports", anchor: .top) + } else if viewModel.isDatesStepVisible { + proxy.scrollTo("dates", anchor: .top) } } @@ -177,37 +184,37 @@ struct TripWizardView: View { endDate: preferences.endDate ) + // Build dictionaries from arrays + let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) }) + let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) }) + // Build planning request let request = PlanningRequest( preferences: preferences, availableGames: games, - teams: AppDataProvider.shared.teams, - stadiums: AppDataProvider.shared.stadiums + teams: teamsById, + stadiums: stadiumsById ) // Run planning engine - let result = await TripPlanningEngine.shared.plan(request: request) + let result = planningEngine.planItineraries(request: request) - await MainActor.run { - switch result { - case .success(let options): - if options.isEmpty { - planningError = "No valid trip options found for your criteria. Try expanding your date range or regions." - showError = true - } else { - tripOptions = options - showTripOptions = true - } - case .failure(let failure): - planningError = failure.message + switch result { + case .success(let options): + if options.isEmpty { + planningError = "No valid trip options found for your criteria. Try expanding your date range or regions." showError = true + } else { + tripOptions = options + showTripOptions = true } - } - } catch { - await MainActor.run { - planningError = error.localizedDescription + case .failure(let failure): + planningError = failure.message showError = true } + } catch { + planningError = error.localizedDescription + showError = true } } @@ -223,6 +230,42 @@ struct TripWizardView: View { selectedRegions: viewModel.selectedRegions ) } + + private func convertOptionToTrip(_ option: ItineraryOption) -> Trip { + let preferences = buildPreferences() + + // Convert ItineraryStops to TripStops + let tripStops = option.stops.enumerated().map { index, stop in + TripStop( + stopNumber: index + 1, + city: stop.city, + state: stop.state, + coordinate: stop.coordinate, + arrivalDate: stop.arrivalDate, + departureDate: stop.departureDate, + games: stop.games, + isRestDay: stop.games.isEmpty + ) + } + + return Trip( + name: generateTripName(from: tripStops), + preferences: preferences, + stops: tripStops, + travelSegments: option.travelSegments, + totalGames: option.totalGames, + totalDistanceMeters: option.totalDistanceMiles * 1609.34, + totalDrivingSeconds: option.totalDrivingHours * 3600 + ) + } + + private func generateTripName(from stops: [TripStop]) -> String { + let cities = stops.compactMap { $0.city }.prefix(3) + if cities.count <= 1 { + return cities.first ?? "Road Trip" + } + return cities.joined(separator: " → ") + } } // MARK: - Preview diff --git a/SportsTimeTests/Trip/TripWizardViewModelTests.swift b/SportsTimeTests/Trip/TripWizardViewModelTests.swift index ce0a186..3a77d18 100644 --- a/SportsTimeTests/Trip/TripWizardViewModelTests.swift +++ b/SportsTimeTests/Trip/TripWizardViewModelTests.swift @@ -45,4 +45,227 @@ final class TripWizardViewModelTests: XCTestCase { XCTAssertTrue(viewModel.selectedSports.isEmpty) XCTAssertFalse(viewModel.hasSetDates) } + + // MARK: - Full Flow Integration Tests + + func test_fullWizardFlow_reachesReviewStep() { + let viewModel = TripWizardViewModel() + + // Step 1: Select planning mode + viewModel.planningMode = .dateRange + XCTAssertTrue(viewModel.isSportsStepVisible) + + // Step 2: Select sport + viewModel.selectedSports = [.mlb] + XCTAssertTrue(viewModel.isDatesStepVisible) + + // Step 3: Set dates + viewModel.hasSetDates = true + XCTAssertTrue(viewModel.isRegionsStepVisible) + + // Step 4: Select regions + viewModel.selectedRegions = [.east] + XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) + + // Step 5: Set route preference + viewModel.hasSetRoutePreference = true + XCTAssertTrue(viewModel.isRepeatCitiesStepVisible) + + // Step 6: Set repeat cities preference + viewModel.hasSetRepeatCities = true + XCTAssertTrue(viewModel.isMustStopsStepVisible) + XCTAssertTrue(viewModel.isReviewStepVisible) + } + + func test_regionSelection_revealsRoutePreference() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.nba] + viewModel.hasSetDates = true + + XCTAssertFalse(viewModel.isRoutePreferenceStepVisible) + + viewModel.selectedRegions = [.central, .west] + + XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) + } + + func test_routePreference_revealsRepeatCities() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + viewModel.selectedRegions = [.east] + + XCTAssertFalse(viewModel.isRepeatCitiesStepVisible) + + viewModel.hasSetRoutePreference = true + + XCTAssertTrue(viewModel.isRepeatCitiesStepVisible) + } + + func test_repeatCities_revealsMustStopsAndReview() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + viewModel.selectedRegions = [.east] + viewModel.hasSetRoutePreference = true + + XCTAssertFalse(viewModel.isMustStopsStepVisible) + XCTAssertFalse(viewModel.isReviewStepVisible) + + viewModel.hasSetRepeatCities = true + + XCTAssertTrue(viewModel.isMustStopsStepVisible) + XCTAssertTrue(viewModel.isReviewStepVisible) + } + + // MARK: - Reset Behavior Tests + + func test_changingPlanningMode_resetsAllDownstreamState() { + let viewModel = TripWizardViewModel() + + // Set up full wizard state + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb, .nba] + viewModel.hasSetDates = true + viewModel.selectedRegions = [.east, .central] + viewModel.hasSetRoutePreference = true + viewModel.hasSetRepeatCities = true + viewModel.mustStopLocations = [LocationInput(name: "Test", coordinates: nil)] + + // Change planning mode + viewModel.planningMode = .locations + + // Verify all downstream state is reset + XCTAssertTrue(viewModel.selectedSports.isEmpty) + XCTAssertFalse(viewModel.hasSetDates) + XCTAssertTrue(viewModel.selectedRegions.isEmpty) + XCTAssertFalse(viewModel.hasSetRoutePreference) + XCTAssertFalse(viewModel.hasSetRepeatCities) + XCTAssertTrue(viewModel.mustStopLocations.isEmpty) + } + + func test_settingSamePlanningMode_doesNotResetState() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + + // Re-set the same mode + viewModel.planningMode = .dateRange + + // State should NOT be reset + XCTAssertEqual(viewModel.selectedSports, [.mlb]) + XCTAssertTrue(viewModel.hasSetDates) + } + + // MARK: - Edge Cases + + func test_emptyRegionSelection_hidesRoutePreference() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + viewModel.selectedRegions = [.west] + XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) + + viewModel.selectedRegions = [] + + XCTAssertFalse(viewModel.isRoutePreferenceStepVisible) + } + + func test_multipleSports_canBeSelected() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + + viewModel.selectedSports = [.mlb, .nba, .nhl] + + XCTAssertEqual(viewModel.selectedSports.count, 3) + XCTAssertTrue(viewModel.isDatesStepVisible) + } + + func test_multipleRegions_canBeSelected() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + + viewModel.selectedRegions = [.east, .central, .west] + + XCTAssertEqual(viewModel.selectedRegions.count, 3) + XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) + } + + // MARK: - Reveal State Bitmask Tests + + func test_revealState_initialValue() { + let viewModel = TripWizardViewModel() + + XCTAssertEqual(viewModel.revealState, 0) + } + + func test_revealState_incrementsWithProgress() { + let viewModel = TripWizardViewModel() + + viewModel.planningMode = .dateRange + XCTAssertEqual(viewModel.revealState, 1) // Sports visible + + viewModel.selectedSports = [.mlb] + XCTAssertEqual(viewModel.revealState, 3) // Sports + Dates + + viewModel.hasSetDates = true + XCTAssertEqual(viewModel.revealState, 7) // Sports + Dates + Regions + } + + func test_revealState_fullFlow() { + let viewModel = TripWizardViewModel() + viewModel.planningMode = .dateRange + viewModel.selectedSports = [.mlb] + viewModel.hasSetDates = true + viewModel.selectedRegions = [.east] + viewModel.hasSetRoutePreference = true + viewModel.hasSetRepeatCities = true + + // 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127 + XCTAssertEqual(viewModel.revealState, 127) + } + + // MARK: - Sport Availability Tests + + func test_canSelectSport_defaultsToTrue() { + let viewModel = TripWizardViewModel() + + XCTAssertTrue(viewModel.canSelectSport(.mlb)) + XCTAssertTrue(viewModel.canSelectSport(.nba)) + XCTAssertTrue(viewModel.canSelectSport(.nhl)) + } + + func test_canSelectSport_respectsAvailability() { + let viewModel = TripWizardViewModel() + viewModel.sportAvailability = [.mlb: true, .nba: false, .nhl: true] + + XCTAssertTrue(viewModel.canSelectSport(.mlb)) + XCTAssertFalse(viewModel.canSelectSport(.nba)) + XCTAssertTrue(viewModel.canSelectSport(.nhl)) + } + + // MARK: - Planning State Tests + + func test_isPlanning_defaultsToFalse() { + let viewModel = TripWizardViewModel() + + XCTAssertFalse(viewModel.isPlanning) + } + + func test_mustStopLocations_canBeAdded() { + let viewModel = TripWizardViewModel() + let location = LocationInput(name: "Chicago, IL", coordinates: nil) + + viewModel.mustStopLocations.append(location) + + XCTAssertEqual(viewModel.mustStopLocations.count, 1) + XCTAssertEqual(viewModel.mustStopLocations.first?.name, "Chicago, IL") + } }