diff --git a/OTPKit/Sources/OTPKit/Core/Notifications.swift b/OTPKit/Sources/OTPKit/Core/Notifications.swift index cd21acb..db62d6c 100644 --- a/OTPKit/Sources/OTPKit/Core/Notifications.swift +++ b/OTPKit/Sources/OTPKit/Core/Notifications.swift @@ -12,6 +12,23 @@ import Foundation /// and/or `TripPlannerViewModel`. public struct Notifications { public static let itinerariesUpdated = NSNotification.Name("org.onebusaway.otpkit.itinerariesUpdated") + /// Posted when a plan request completes without a usable itinerary: the server answered with an + /// error (for example `PATH_NOT_FOUND`) or with an empty plan. `userInfo[tripPlanEmptyReasonKey]` + /// is `tripPlanEmptyReasonError` or `tripPlanEmptyReasonEmpty`, and the origin and destination of + /// that request travel under the coordinate keys as `Double`s. Hosts use this to offer alternatives + /// such as on-demand services. + public static let tripPlanEmpty = NSNotification.Name("org.onebusaway.otpkit.tripPlanEmpty") + /// The `userInfo` key under which `tripPlanEmpty` carries its reason. + public static let tripPlanEmptyReasonKey = "reason" + /// `tripPlanEmpty` reason: the server answered with an error such as `PATH_NOT_FOUND`. + public static let tripPlanEmptyReasonError = "error" + /// `tripPlanEmpty` reason: the server answered with a plan that has no itineraries. + public static let tripPlanEmptyReasonEmpty = "empty" + /// `userInfo` keys under which `tripPlanEmpty` carries the planned endpoints, as `Double`s. + public static let tripPlanEmptyOriginLatitudeKey = "originLatitude" + public static let tripPlanEmptyOriginLongitudeKey = "originLongitude" + public static let tripPlanEmptyDestinationLatitudeKey = "destinationLatitude" + public static let tripPlanEmptyDestinationLongitudeKey = "destinationLongitude" public static let itineraryPreviewStarted = NSNotification.Name("org.onebusaway.otpkit.itineraryPreviewStarted") public static let itineraryPreviewEnded = NSNotification.Name("org.onebusaway.otpkit.itineraryPreviewEnded") public static let tripStarted = NSNotification.Name("org.onebusaway.otpkit.tripStarted") diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel+EmptyPlan.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel+EmptyPlan.swift new file mode 100644 index 0000000..abe4ba8 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel+EmptyPlan.swift @@ -0,0 +1,28 @@ +// +// TripPlannerViewModel+EmptyPlan.swift +// OTPKit +// +// Copyright © Open Transit Software Foundation +// This source code is licensed under the Apache 2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +import CoreLocation +import Foundation + +// MARK: - Empty plan notification + +extension TripPlannerViewModel { + /// Tells the host that the plan came back with nothing usable, with the endpoints of the request + /// that produced it (not the current selection, which the rider may have changed since). + func postTripPlanEmpty(reason: String, for request: TripPlanRequest) { + let userInfo: [String: Any] = [ + Notifications.tripPlanEmptyReasonKey: reason, + Notifications.tripPlanEmptyOriginLatitudeKey: request.origin.latitude, + Notifications.tripPlanEmptyOriginLongitudeKey: request.origin.longitude, + Notifications.tripPlanEmptyDestinationLatitudeKey: request.destination.latitude, + Notifications.tripPlanEmptyDestinationLongitudeKey: request.destination.longitude + ] + notificationCenter.post(name: Notifications.tripPlanEmpty, object: nil, userInfo: userInfo) + } +} diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index c00614f..9019b99 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -98,7 +98,7 @@ public class TripPlannerViewModel: ObservableObject { private let mapCoordinator: MapCoordinator /// NotificationCenter object for sending notifications. - private let notificationCenter: NotificationCenter + let notificationCenter: NotificationCenter /// Flag to prevent saving during initialization private var isInitializationComplete = false @@ -236,14 +236,19 @@ public class TripPlannerViewModel: ObservableObject { errorMessage = nil showingError = false + // A newer plan supersedes any request still in flight; its late response must not + // overwrite this one's results or notify the host about stale endpoints. + activePlanTask?.cancel() activePlanTask = Task { do { let response = try await apiService.fetchPlan(request) await MainActor.run { - self.handlePlanResponse(response) + guard !Task.isCancelled else { return } + self.handlePlanResponse(response, for: request) } } catch { await MainActor.run { + guard !Task.isCancelled else { return } self.handleError(error) } } @@ -281,7 +286,7 @@ public class TripPlannerViewModel: ObservableObject { } } - private func handlePlanResponse(_ response: OTPResponse) { + private func handlePlanResponse(_ response: OTPResponse, for request: TripPlanRequest) { isLoading = false if let error = response.error { @@ -290,10 +295,16 @@ public class TripPlannerViewModel: ObservableObject { tripPlanResponse = nil showError(error.messageCode.displayMessage) + postTripPlanEmpty(reason: Notifications.tripPlanEmptyReasonError, for: request) } else { + // Classify from the response itself: an itinerariesUpdated observer may change state. + let isEmpty = response.plan?.itineraries.isEmpty ?? true tripPlanResponse = response HapticManager.shared.success() notificationCenter.post(name: Notifications.itinerariesUpdated, object: nil) + if isEmpty { + postTripPlanEmpty(reason: Notifications.tripPlanEmptyReasonEmpty, for: request) + } } } @@ -396,6 +407,10 @@ public class TripPlannerViewModel: ObservableObject { /// Reset all trip planner state to initial values /// Clears locations, itineraries, and returns to clean state func resetTripPlanner() { + // Drop any plan still in flight so its response can't repopulate the cleared state. + activePlanTask?.cancel() + activePlanTask = nil + // Clear location selections selectedOrigin = nil selectedDestination = nil diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index e154aa3..969eceb 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -42,7 +42,8 @@ struct TripPlannerViewModelTests { func createViewModel( enabledModes: [TransportMode] = [.transit, .walk, .bike], - mockAPIService: TestFixtures.MockAPIService? = nil + mockAPIService: TestFixtures.MockAPIService? = nil, + notificationCenter: NotificationCenter = .default ) -> TripPlannerViewModel { // Clear UserDefaults before each test to ensure clean state let defaults = UserDefaults.standard @@ -59,10 +60,46 @@ struct TripPlannerViewModelTests { return TripPlannerViewModel( config: config, apiService: apiService, - mapCoordinator: mapCoordinator + mapCoordinator: mapCoordinator, + notificationCenter: notificationCenter ) } + static let emptyPlanOrigin = TestHelpers.location(title: "Origin", lat: 37.78, lon: -122.41) + static let emptyPlanDestination = TestHelpers.location(title: "Destination", lat: 37.80, lon: -122.40) + + /// Runs `planTrip()` against `response` on a private notification center and returns the + /// reasons of every `tripPlanEmpty` notification it posted. + func tripPlanEmptyReasons(for response: OTPResponse) async -> [String] { + await tripPlanEmptyNotifications(for: response).map { $0.userInfo?[Notifications.tripPlanEmptyReasonKey] as? String ?? "" } + } + + /// Runs `planTrip()` against `response` on a private notification center and returns every + /// `tripPlanEmpty` notification it posted. `whileInFlight` runs after the request starts and + /// before its response is handled. + func tripPlanEmptyNotifications( + for response: OTPResponse, + whileInFlight: (TripPlannerViewModel) -> Void = { _ in } + ) async -> [Notification] { + let mockAPIService = TestFixtures.MockAPIService() + mockAPIService.mockResponse = response + let center = NotificationCenter() + var notifications: [Notification] = [] + let token = center.addObserver(forName: Notifications.tripPlanEmpty, object: nil, queue: nil) { note in + notifications.append(note) + } + defer { center.removeObserver(token) } + + let viewModel = createViewModel(mockAPIService: mockAPIService, notificationCenter: center) + viewModel.selectedOrigin = Self.emptyPlanOrigin + viewModel.selectedDestination = Self.emptyPlanDestination + viewModel.planTrip() + let inFlight = viewModel.activePlanTask + whileInFlight(viewModel) + await inFlight?.value + return notifications + } + // MARK: - Initialization Tests @Test("ViewModel initializes with correct defaults") @@ -270,6 +307,55 @@ struct TripPlannerViewModelTests { #expect(mockAPIService.fetchPlanCallCount == 1) } + @Test("planTrip posts tripPlanEmpty when the plan has no itineraries") + func planTripPostsEmptyForEmptyPlan() async throws { + let reasons = await tripPlanEmptyReasons(for: TestHelpers.response(with: [])) + #expect(reasons == [Notifications.tripPlanEmptyReasonEmpty]) + } + + @Test("planTrip posts tripPlanEmpty when the server reports an error") + func planTripPostsEmptyForServerError() async throws { + let errorResponse = try JSONDecoder().decode(OTPResponse.self, from: Fixtures.loadData(file: "otp_response_error.json")) + let reasons = await tripPlanEmptyReasons(for: errorResponse) + #expect(reasons == [Notifications.tripPlanEmptyReasonError]) + } + + @Test("tripPlanEmpty carries the planned origin and destination") + func tripPlanEmptyCarriesEndpoints() async throws { + let notifications = await tripPlanEmptyNotifications(for: TestHelpers.response(with: [])) + let userInfo = try #require(notifications.first?.userInfo) + let origin = Self.emptyPlanOrigin + let destination = Self.emptyPlanDestination + #expect(userInfo[Notifications.tripPlanEmptyOriginLatitudeKey] as? Double == origin.latitude) + #expect(userInfo[Notifications.tripPlanEmptyOriginLongitudeKey] as? Double == origin.longitude) + #expect(userInfo[Notifications.tripPlanEmptyDestinationLatitudeKey] as? Double == destination.latitude) + #expect(userInfo[Notifications.tripPlanEmptyDestinationLongitudeKey] as? Double == destination.longitude) + } + + @Test("tripPlanEmpty carries the requested endpoints even if the selection changed in flight") + func tripPlanEmptyUsesRequestEndpoints() async throws { + let notifications = await tripPlanEmptyNotifications(for: TestHelpers.response(with: [])) { viewModel in + viewModel.selectedOrigin = TestHelpers.location(title: "Elsewhere", lat: 47.6, lon: -122.3) + } + let userInfo = try #require(notifications.first?.userInfo) + #expect(userInfo[Notifications.tripPlanEmptyOriginLatitudeKey] as? Double == Self.emptyPlanOrigin.latitude) + #expect(userInfo[Notifications.tripPlanEmptyOriginLongitudeKey] as? Double == Self.emptyPlanOrigin.longitude) + } + + @Test("a plan cancelled by reset does not post tripPlanEmpty") + func resetCancelsInFlightPlan() async throws { + let notifications = await tripPlanEmptyNotifications(for: TestHelpers.response(with: [])) { viewModel in + viewModel.resetTripPlanner() + } + #expect(notifications.isEmpty) + } + + @Test("planTrip does not post tripPlanEmpty when itineraries are returned") + func planTripDoesNotPostEmptyForItineraries() async throws { + let reasons = await tripPlanEmptyReasons(for: TestHelpers.response(with: [TestHelpers.itinerary()])) + #expect(reasons.isEmpty) + } + @Test("planTrip sets loading state correctly") func planTripSetsLoadingState() async throws { let mockAPIService = TestFixtures.MockAPIService()