Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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
Expand Down
90 changes: 88 additions & 2 deletions OTPKit/Tests/TripPlannerViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Loading