diff --git a/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift b/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift index c737462..fee39f4 100644 --- a/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift +++ b/OTPKit/Sources/OTPKit/Core/Extensions/StringExtension.swift @@ -14,6 +14,12 @@ extension String { .replacingOccurrences(of: " ", with: "_") } + /// Uppercases only the first character, leaving the rest untouched: `e-bike` becomes + /// `E-bike`. Unlike `capitalized`, this never lowercases the remainder. + var capitalizedFirst: String { + isEmpty ? self : prefix(1).uppercased() + dropFirst() + } + /// Renders an unrecognized OTP token as readable text: `SPIN_AROUND` becomes `Spin Around`. /// /// Last-resort display fallback for a mode or direction this client doesn't know about. diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift index 88e91fe..ad9f57a 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift @@ -75,6 +75,11 @@ public struct Leg: Codable, Hashable { /// Optional flag indicating whether this leg involves transit. public let transitLeg: Bool? + /// True when this leg is ridden on a rented vehicle (bikeshare/micromobility). + /// Present in both OTP 1.x REST and 2.x GraphQL responses; the ride leg's `mode` + /// is plain "BICYCLE", so this flag is the only reliable rental discriminator. + public let rentedBike: Bool? + /// Duration of the leg in seconds. public let duration: Int @@ -124,7 +129,8 @@ public struct Leg: Codable, Hashable { headsign: String?, intermediateStops: [Place]?, departureDelay: Int? = nil, - arrivalDelay: Int? = nil + arrivalDelay: Int? = nil, + rentedBike: Bool? = nil ) { self.startTime = startTime self.endTime = endTime @@ -148,6 +154,7 @@ public struct Leg: Codable, Hashable { self.intermediateStops = intermediateStops self.departureDelay = departureDelay self.arrivalDelay = arrivalDelay + self.rentedBike = rentedBike } /// Merges `Itinerary` `Leg`s that are part of the same route on the same vehicle. @@ -178,7 +185,8 @@ public struct Leg: Codable, Hashable { headsign: leg1.headsign, intermediateStops: leg1.intermediateStops, departureDelay: leg1.departureDelay, - arrivalDelay: leg2.arrivalDelay + arrivalDelay: leg2.arrivalDelay, + rentedBike: leg1.rentedBike ) } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift index 03fa35b..bbed44f 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/Place.swift @@ -36,18 +36,24 @@ public struct Place: Codable, Hashable { // StopCode of the stop public let stopCode: String? + /// Identifier of the vehicle rental entity at this place — a station's `stationId` or a + /// free-floating vehicle's `vehicleId`, whichever the leg references. Nil for non-rental places. + public let bikeShareId: String? + /// Custom initializer for creating Place instances public init(name: String, lon: Double, lat: Double, vertexType: String, stopId: String? = nil, - stopCode: String? = nil) { + stopCode: String? = nil, + bikeShareId: String? = nil) { self.name = name self.lon = lon self.lat = lat self.vertexType = vertexType self.stopId = stopId self.stopCode = stopCode + self.bikeShareId = bikeShareId } } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift new file mode 100644 index 0000000..1751008 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/RentalVehicle.swift @@ -0,0 +1,51 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import CoreLocation +import Foundation + +/// A free-floating rental vehicle (dockless bike, scooter, etc.) — the dominant +/// entity type on real feeds. +public struct RentalVehicle: Codable, Hashable, Sendable { + public let vehicleId: String + /// Raw feed name. Often a placeholder like "Default vehicle type" — surface + /// `VehicleRental.displayLabel` to riders instead. + public let name: String + public let lat: Double + public let lon: Double + public let allowPickupNow: Bool? + public let operative: Bool? + public let rentalNetwork: RentalNetwork? + public let rentalUris: RentalUris? + public let vehicleType: VehicleType? + public let fuel: FuelInfo? + + public var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + /// Whether the vehicle is in service. Treats missing data as operative. + public var isOperative: Bool { + operative ?? true + } + + /// True when the vehicle matches one of the given form factors. + /// Fail-open: a vehicle with no typed data is assumed to match. + public func matches(formFactors: Set) -> Bool { + guard let formFactor = vehicleType?.formFactor else { return true } + return formFactors.contains(formFactor) + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift index 817f37b..014830c 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/TransportMode.swift @@ -17,6 +17,9 @@ public enum TransportMode: String, CaseIterable, Codable { case bike = "BIKE" /// Driving case car = "CAR" + /// Rented bicycle/micromobility (bikeshare). The raw value is the OTP 1.x REST wire + /// token; the GraphQL service translates it to `{mode: BICYCLE, qualifier: RENT}`. + case bikeRental = "BICYCLE_RENT" /// Localized, human-readable description of the transport mode public var displayName: String { @@ -29,6 +32,8 @@ public enum TransportMode: String, CaseIterable, Codable { return OTPLoc("transport_mode.bike", comment: "Transport mode: Bike") case .car: return OTPLoc("transport_mode.car", comment: "Transport mode: Car") + case .bikeRental: + return OTPLoc("transport_mode.bike_rental", comment: "Transport mode: Bike Rental") } } @@ -43,6 +48,8 @@ public enum TransportMode: String, CaseIterable, Codable { return "bicycle" case .car: return "car" + case .bikeRental: + return "bicycle.circle" } } @@ -58,6 +65,8 @@ public enum TransportMode: String, CaseIterable, Codable { return [.bike, .walk] case .car: return [.car] + case .bikeRental: + return [.bikeRental, .walk] } } } diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleFormFactor.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleFormFactor.swift new file mode 100644 index 0000000..0cc7032 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleFormFactor.swift @@ -0,0 +1,57 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// The physical form factor of a rental vehicle, mirroring OTP's `FormFactor` GraphQL enum. +/// +/// Decodes fail-open: an unrecognized wire value becomes `.other` instead of throwing, +/// so one novel vehicle type can never invalidate an entire multi-thousand-entity payload. +public enum VehicleFormFactor: String, Codable, Hashable, Sendable, CaseIterable { + case bicycle = "BICYCLE" + case cargoBicycle = "CARGO_BICYCLE" + case car = "CAR" + case moped = "MOPED" + case scooter = "SCOOTER" + case scooterSeated = "SCOOTER_SEATED" + case scooterStanding = "SCOOTER_STANDING" + case other = "OTHER" + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = VehicleFormFactor(rawValue: raw.normalizedOTPToken) ?? .other + } + + /// True for any scooter variant (standing, seated, or unspecified). + public var isScooter: Bool { + switch self { + case .scooter, .scooterSeated, .scooterStanding: + return true + case .bicycle, .cargoBicycle, .car, .moped, .other: + return false + } + } + + /// True for any bicycle variant (including cargo bikes). + public var isBicycle: Bool { + switch self { + case .bicycle, .cargoBicycle: + return true + case .scooter, .scooterSeated, .scooterStanding, .car, .moped, .other: + return false + } + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift new file mode 100644 index 0000000..f9c06b5 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRental.swift @@ -0,0 +1,168 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import CoreLocation +import Foundation + +/// A vehicle rental entity — either a docked station or a free-floating vehicle. +/// +/// Decodes the GTFS GraphQL `RentalPlace` union using `__typename` discrimination. +/// `Decodable` only: these are read from responses and never serialized back. +public enum VehicleRental: Identifiable, Hashable, Sendable { + case station(VehicleRentalStation) + case vehicle(RentalVehicle) + + public var id: String { + switch self { + case .station(let station): return station.stationId + case .vehicle(let vehicle): return vehicle.vehicleId + } + } + + // MARK: - Convenience Accessors + + /// The raw feed name. Prefer `displayLabel` for rider-facing UI. + public var name: String { + switch self { + case .station(let station): return station.name + case .vehicle(let vehicle): return vehicle.name + } + } + + public var coordinate: CLLocationCoordinate2D { + switch self { + case .station(let station): return station.coordinate + case .vehicle(let vehicle): return vehicle.coordinate + } + } + + /// Whether the entity is in service. Treats missing data as operative. + public var isOperative: Bool { + switch self { + case .station(let station): return station.isOperative + case .vehicle(let vehicle): return vehicle.isOperative + } + } + + public var rentalNetwork: RentalNetwork? { + switch self { + case .station(let station): return station.rentalNetwork + case .vehicle(let vehicle): return vehicle.rentalNetwork + } + } + + public var rentalUris: RentalUris? { + switch self { + case .station(let station): return station.rentalUris + case .vehicle(let vehicle): return vehicle.rentalUris + } + } + + /// Battery charge ratio in 0...1, when the feed provides it. Frequently nil. + public var batteryPercent: Double? { + switch self { + case .station: return nil + case .vehicle(let vehicle): return vehicle.fuel?.percent + } + } + + /// True when the entity matches one of the given form factors (fail-open on + /// missing typed data — see the underlying model's `matches(formFactors:)`). + public func matches(formFactors: Set) -> Bool { + switch self { + case .station(let station): return station.matches(formFactors: formFactors) + case .vehicle(let vehicle): return vehicle.matches(formFactors: formFactors) + } + } + + // MARK: - Display Label + + /// A rider-facing label, e.g. "Lime e-bike" or "Pine St Station". Never surfaces + /// known feed placeholders like "Default vehicle type". + public var displayLabel: String { + switch self { + case .station(let station): + return station.name + + case .vehicle(let vehicle): + let typeName = Self.localizedTypeName(for: vehicle.vehicleType) + + if let network = vehicle.rentalNetwork?.displayName, !network.isEmpty { + return "\(network) \(typeName)" + } + + let trimmedName = vehicle.name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty, !Self.isPlaceholderName(trimmedName) { + return trimmedName + } + + return typeName.capitalizedFirst + } + } + + private static func isPlaceholderName(_ name: String) -> Bool { + name.lowercased() == "default vehicle type" + } + + private static func localizedTypeName(for vehicleType: VehicleType?) -> String { + guard let vehicleType, let formFactor = vehicleType.formFactor else { + return OTPLoc("rental.vehicle_type.vehicle", comment: "Generic rental vehicle type name") + } + + if formFactor.isBicycle { + return vehicleType.isPowered + ? OTPLoc("rental.vehicle_type.ebike", comment: "Rental vehicle type: electric bike") + : OTPLoc("rental.vehicle_type.bike", comment: "Rental vehicle type: bike") + } + if formFactor.isScooter { + return OTPLoc("rental.vehicle_type.scooter", comment: "Rental vehicle type: scooter") + } + + switch formFactor { + case .car: + return OTPLoc("rental.vehicle_type.car", comment: "Rental vehicle type: car") + case .moped: + return OTPLoc("rental.vehicle_type.moped", comment: "Rental vehicle type: moped") + default: + return OTPLoc("rental.vehicle_type.vehicle", comment: "Generic rental vehicle type name") + } + } +} + +// MARK: - Decodable + +extension VehicleRental: Decodable { + private enum TypeNameCodingKeys: String, CodingKey { + case typename = "__typename" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: TypeNameCodingKeys.self) + let typename = try container.decode(String.self, forKey: .typename) + + switch typename { + case "VehicleRentalStation": + self = .station(try VehicleRentalStation(from: decoder)) + case "RentalVehicle": + self = .vehicle(try RentalVehicle(from: decoder)) + default: + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown RentalPlace __typename: \(typename)" + )) + } + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalBoundingBox.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalBoundingBox.swift new file mode 100644 index 0000000..69e73bd --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalBoundingBox.swift @@ -0,0 +1,38 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// A geographic bounding box using raw coordinates, keeping MapKit out of the +/// network layer. Hosts provide conveniences from `MKCoordinateRegion`/`MKMapRect`. +public struct VehicleRentalBoundingBox: Equatable, Sendable { + public let minimumLatitude: Double + public let maximumLatitude: Double + public let minimumLongitude: Double + public let maximumLongitude: Double + + public init( + minimumLatitude: Double, + maximumLatitude: Double, + minimumLongitude: Double, + maximumLongitude: Double + ) { + self.minimumLatitude = minimumLatitude + self.maximumLatitude = maximumLatitude + self.minimumLongitude = minimumLongitude + self.maximumLongitude = maximumLongitude + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalFetchResult.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalFetchResult.swift new file mode 100644 index 0000000..0c728bf --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalFetchResult.swift @@ -0,0 +1,34 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// The result of a rental fetch. +/// +/// GraphQL responses can carry both `data` and `errors` (partial success). A throwing +/// `[VehicleRental]` return couldn't convey that, so the result carries the usable +/// entities alongside any non-fatal error messages — a half-populated map beats an +/// error state for a browse layer. +public struct VehicleRentalFetchResult: Sendable { + public let rentals: [VehicleRental] + /// Non-fatal GraphQL error messages that accompanied partial data. Empty on full success. + public let partialErrors: [String] + + public init(rentals: [VehicleRental], partialErrors: [String] = []) { + self.rentals = rentals + self.partialErrors = partialErrors + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift new file mode 100644 index 0000000..fabb2d3 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalStation.swift @@ -0,0 +1,72 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import CoreLocation +import Foundation + +/// A docked vehicle rental station with availability info. +/// +/// Availability comes in two generations: the typed `availableVehicles`/`availableSpaces` +/// breakdowns, and the older flat `vehiclesAvailable`/`spacesAvailable` counts. Both are +/// nullable on the wire; use `bikesAvailableCount`/`docksAvailableCount` which prefer the +/// typed data and fall back to the flat counts. +public struct VehicleRentalStation: Codable, Hashable, Sendable { + public let stationId: String + public let name: String + public let lat: Double + public let lon: Double + public let vehiclesAvailable: Int? + public let spacesAvailable: Int? + public let allowPickupNow: Bool? + public let allowDropoffNow: Bool? + public let operative: Bool? + public let rentalNetwork: RentalNetwork? + public let rentalUris: RentalUris? + public let availableVehicles: AvailableVehicles? + public let availableSpaces: AvailableSpaces? + + public var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + /// Vehicles available for pickup, preferring typed availability data. + public var vehiclesAvailableCount: Int? { + availableVehicles?.total ?? vehiclesAvailable + } + + /// Docks available for dropoff, preferring typed availability data. + public var docksAvailableCount: Int? { + availableSpaces?.total ?? spacesAvailable + } + + /// Whether the station is in service. Treats missing data as operative. + public var isOperative: Bool { + operative ?? true + } + + /// True when the station stocks any vehicle matching one of the given form factors. + /// Fail-open: a station with no typed availability breakdown is assumed to match. + public func matches(formFactors: Set) -> Bool { + guard let byType = availableVehicles?.byType, !byType.isEmpty else { + return true + } + + return byType.contains { typeCount in + guard let formFactor = typeCount.vehicleType.formFactor else { return true } + return formFactors.contains(formFactor) + } + } +} diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSupportingTypes.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSupportingTypes.swift new file mode 100644 index 0000000..046772b --- /dev/null +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/VehicleRentalSupportingTypes.swift @@ -0,0 +1,82 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// Supporting types for vehicle rental entities. Field names match the +// GTFS GraphQL API exactly so these decode straight off the wire. + +/// The rental system (GBFS feed) an entity belongs to, e.g. `lime_seattle`. +public struct RentalNetwork: Codable, Hashable, Sendable { + public let networkId: String + public let url: String? + + /// A rider-facing operator name derived from the network identifier: + /// `lime_seattle` → "Lime", `bird-seattle-washington` → "Bird". + public var displayName: String { + let token = networkId + .split(whereSeparator: { $0 == "_" || $0 == "-" }) + .first + .map(String.init) ?? networkId + return token.isEmpty ? networkId : token.capitalizedFirst + } +} + +/// GBFS deep-link URIs for opening an entity in the operator's app or website. +/// Frequently absent — the Seattle Lime feed publishes none. +public struct RentalUris: Codable, Hashable, Sendable { + public let ios: String? + public let android: String? + public let web: String? +} + +/// The kind of vehicle: form factor plus propulsion, e.g. bicycle + `ELECTRIC_ASSIST`. +public struct VehicleType: Codable, Hashable, Sendable { + public let formFactor: VehicleFormFactor? + public let propulsionType: String? + + /// True when the vehicle is powered (electric, electric-assist, combustion, etc.). + public var isPowered: Bool { + guard let propulsionType else { return false } + return propulsionType.uppercased() != "HUMAN" + } +} + +/// Battery/fuel state of a vehicle. On some feeds (Seattle Lime) `percent` is +/// always nil while `range` is populated — never assume battery data exists. +public struct FuelInfo: Codable, Hashable, Sendable { + /// Charge ratio in 0...1, when the feed provides it. + public let percent: Double? + /// Estimated remaining range in meters, when the feed provides it. + public let range: Int? +} + +/// Vehicles available at a station, optionally broken down by type. +public struct AvailableVehicles: Codable, Hashable, Sendable { + public let total: Int? + public let byType: [VehicleTypeCount]? +} + +/// Docks/spaces available at a station. +public struct AvailableSpaces: Codable, Hashable, Sendable { + public let total: Int? +} + +/// A per-type availability count at a station. +public struct VehicleTypeCount: Codable, Hashable, Sendable { + public let count: Int + public let vehicleType: VehicleType +} diff --git a/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift b/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift index 5d6b1d0..b40ab80 100644 --- a/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift +++ b/OTPKit/Sources/OTPKit/Core/OTPConfiguration.swift @@ -26,7 +26,9 @@ public struct OTPConfiguration { public init( otpServerURL: URL, - enabledTransportModes: [TransportMode] = TransportMode.allCases, + // An explicit list, not `allCases`: new framework modes (like .bikeRental, which + // needs backend rental support) must be opted into by hosts, never inherited. + enabledTransportModes: [TransportMode] = [.transit, .walk, .bike, .car], themeConfiguration: OTPThemeConfiguration = OTPThemeConfiguration(), searchRegion: MKCoordinateRegion ) { diff --git a/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift b/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift index 4812fc9..9b1b761 100644 --- a/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift +++ b/OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift @@ -17,8 +17,9 @@ import Foundation import OSLog -/// Actor-based GraphQL API client for OTP 2.x trip planning via the GTFS GraphQL API. -public actor GraphQLAPIService: APIService { +/// Actor-based GraphQL API client for OTP 2.x trip planning and vehicle rentals +/// via the GTFS GraphQL API. +public actor GraphQLAPIService: APIService, VehicleRentalService { public nonisolated let baseURL: URL public nonisolated let dataLoader: URLDataLoader @@ -40,18 +41,15 @@ public actor GraphQLAPIService: APIService { /// Fetches a trip plan using a `TripPlanRequest` public func fetchPlan(_ request: TripPlanRequest) async throws -> OTPResponse { - var urlRequest = URLRequest(url: endpointURL) - urlRequest.httpMethod = "POST" - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: [ - "query": Self.planQuery, - "variables": Self.planVariables(for: request) - ]) + let urlRequest = try makeGraphQLRequest( + query: Self.planQuery, + variables: Self.planVariables(for: request) + ) Logger.main.info("Fetching trip plan via GraphQL: \(self.endpointURL.absoluteString)") let data = try await dataLoader.validatedData(for: urlRequest) - let envelope = try JSONDecoder.otpDecoder().decode(GraphQLResponseEnvelope.self, from: data) + let envelope = try JSONDecoder.otpDecoder().decode(GraphQLEnvelope.self, from: data) if let firstError = envelope.errors?.first { throw OTPKitError.apiError(firstError.message) @@ -68,8 +66,78 @@ public actor GraphQLAPIService: APIService { ) } + // MARK: - Vehicle Rentals + + /// Fetches rental stations and free-floating vehicles in a bounding box. + /// + /// Unlike `fetchPlan`, this tolerates GraphQL partial success: a response carrying + /// both data and errors returns the data, with the error messages surfaced in + /// `VehicleRentalFetchResult.partialErrors`. Unrecognized `RentalPlace` union + /// members are skipped (and logged), not treated as a failed fetch. + public func fetchVehicleRentals( + in boundingBox: VehicleRentalBoundingBox, + formFactors: Set? + ) async throws -> VehicleRentalFetchResult { + let urlRequest = try makeGraphQLRequest( + query: Self.rentalsQuery, + variables: [ + "minLat": boundingBox.minimumLatitude, + "maxLat": boundingBox.maximumLatitude, + "minLon": boundingBox.minimumLongitude, + "maxLon": boundingBox.maximumLongitude + ] + ) + + Logger.main.info("Fetching vehicle rentals via GraphQL: \(self.endpointURL.absoluteString)") + + let data = try await dataLoader.validatedData(for: urlRequest) + return try await Self.decodeRentals(data, formFactors: formFactors) + } + + /// Decodes and filters a rentals payload. Nonisolated *async* so it hops to the + /// global concurrent executor — a multi-thousand-entity decode must never hold the + /// actor and serialize a concurrent `fetchPlan` behind it. + private nonisolated static func decodeRentals( + _ data: Data, + formFactors: Set? + ) async throws -> VehicleRentalFetchResult { + let envelope = try JSONDecoder.otpDecoder().decode(GraphQLEnvelope.self, from: data) + + guard let payload = envelope.data, payload.vehicleRentalsByBbox != nil else { + if let firstError = envelope.errors?.first { + throw OTPKitError.apiError(firstError.message) + } + throw OTPKitError.invalidResponse() + } + let rentals = payload.rentals + + let filtered: [VehicleRental] + if let formFactors { + filtered = rentals.filter { $0.matches(formFactors: formFactors) } + } else { + filtered = rentals + } + + return VehicleRentalFetchResult( + rentals: filtered, + partialErrors: envelope.errors?.map(\.message) ?? [] + ) + } + // MARK: - Request Building + /// Assembles the POST request shared by every GraphQL operation. + private func makeGraphQLRequest(query: String, variables: [String: Any]) throws -> URLRequest { + var urlRequest = URLRequest(url: endpointURL) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "query": query, + "variables": variables + ]) + return urlRequest + } + /// Builds the GraphQL `variables` payload for a trip plan request. private static func planVariables(for request: TripPlanRequest) -> [String: Any] { [ @@ -77,21 +145,24 @@ public actor GraphQLAPIService: APIService { "to": ["lat": request.destination.latitude, "lon": request.destination.longitude], "date": request.date.formattedTripDate, "time": request.time.formattedTripTime, - "transportModes": request.transportModes.map { ["mode": graphQLModeName(for: $0)] }, + "transportModes": request.transportModes.map { graphQLTransportMode(for: $0) }, "arriveBy": request.arriveBy, "wheelchair": request.wheelchairAccessible, "maxWalkDistance": Double(request.maxWalkDistance) ] } - /// The GraphQL `Mode` enum value for a transport mode. `TransportMode.rawValue` is the - /// OTP 1.x REST token, which mostly — but not always — matches the GraphQL vocabulary. - private static func graphQLModeName(for mode: TransportMode) -> String { + /// The GraphQL `TransportMode` input value for a transport mode. `TransportMode.rawValue` + /// is the OTP 1.x REST token, which mostly — but not always — matches the GraphQL + /// vocabulary; rentals additionally need the `RENT` qualifier. + private static func graphQLTransportMode(for mode: TransportMode) -> [String: String] { switch mode { case .bike: - return "BICYCLE" + return ["mode": "BICYCLE"] + case .bikeRental: + return ["mode": "BICYCLE", "qualifier": "RENT"] case .transit, .walk, .car: - return mode.rawValue + return ["mode": mode.rawValue] } } @@ -178,11 +249,20 @@ public actor GraphQLAPIService: APIService { textColor agency { name } } - from { name lon lat vertexType stop { gtfsId code } } - to { name lon lat vertexType stop { gtfsId code } } + from { + name lon lat vertexType stop { gtfsId code } + vehicleRentalStation { stationId } + rentalVehicle { vehicleId } + } + to { + name lon lat vertexType stop { gtfsId code } + vehicleRentalStation { stationId } + rentalVehicle { vehicleId } + } legGeometry { points length } distance transitLeg + rentedBike duration realTime departureDelay @@ -195,4 +275,54 @@ public actor GraphQLAPIService: APIService { } } """ + + /// The GTFS GraphQL API `vehicleRentalsByBbox` query. Returns the `RentalPlace` + /// union; `__typename` discriminates stations from free-floating vehicles. + static let rentalsQuery = """ + query VehicleRentalsByBbox( + $minLat: CoordinateValue! + $maxLat: CoordinateValue! + $minLon: CoordinateValue! + $maxLon: CoordinateValue! + ) { + vehicleRentalsByBbox( + minimumLatitude: $minLat + maximumLatitude: $maxLat + minimumLongitude: $minLon + maximumLongitude: $maxLon + ) { + __typename + ... on VehicleRentalStation { + stationId + name + lat + lon + vehiclesAvailable + spacesAvailable + allowPickupNow + allowDropoffNow + operative + rentalNetwork { networkId url } + rentalUris { ios android web } + availableVehicles { + total + byType { count vehicleType { formFactor } } + } + availableSpaces { total } + } + ... on RentalVehicle { + vehicleId + name + lat + lon + allowPickupNow + operative + rentalNetwork { networkId url } + rentalUris { ios android web } + vehicleType { formFactor propulsionType } + fuel { percent range } + } + } + } + """ } diff --git a/OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift b/OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift index 38e8a4a..893688c 100644 --- a/OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift +++ b/OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift @@ -20,8 +20,9 @@ import OSLog // Internal wire types for the OTP 2.x GTFS GraphQL API `plan` query, plus the // mapping that converts them into the public models shared with the REST path. -struct GraphQLResponseEnvelope: Decodable { - let data: GraphQLPlanData? +/// The standard GraphQL response envelope, generic over the query's payload shape. +struct GraphQLEnvelope: Decodable { + let data: Payload? let errors: [GraphQLErrorMessage]? } @@ -69,6 +70,7 @@ struct GraphQLLeg: Decodable { let legGeometry: GraphQLLegGeometry? let distance: Double let transitLeg: Bool? + let rentedBike: Bool? let duration: Double let realTime: Bool? let departureDelay: Int? @@ -96,6 +98,8 @@ struct GraphQLPlace: Decodable { let lat: Double let vertexType: String? let stop: GraphQLStop? + let vehicleRentalStation: GraphQLVehicleRentalStationRef? + let rentalVehicle: GraphQLRentalVehicleRef? } struct GraphQLStop: Decodable { @@ -103,6 +107,14 @@ struct GraphQLStop: Decodable { let code: String? } +struct GraphQLVehicleRentalStationRef: Decodable { + let stationId: String? +} + +struct GraphQLRentalVehicleRef: Decodable { + let vehicleId: String? +} + struct GraphQLLegGeometry: Decodable { let points: String? let length: Int? @@ -208,7 +220,8 @@ extension GraphQLLeg { headsign: headsign, intermediateStops: intermediatePlaces?.map { $0.toPlace() }, departureDelay: departureDelay, - arrivalDelay: arrivalDelay + arrivalDelay: arrivalDelay, + rentedBike: rentedBike ) } } @@ -221,7 +234,8 @@ extension GraphQLPlace { lat: lat, vertexType: vertexType ?? "NORMAL", stopId: stop?.gtfsId, - stopCode: stop?.code + stopCode: stop?.code, + bikeShareId: vehicleRentalStation?.stationId ?? rentalVehicle?.vehicleId ) } } diff --git a/OTPKit/Sources/OTPKit/Network/GraphQLRentalsResponse.swift b/OTPKit/Sources/OTPKit/Network/GraphQLRentalsResponse.swift new file mode 100644 index 0000000..2897550 --- /dev/null +++ b/OTPKit/Sources/OTPKit/Network/GraphQLRentalsResponse.swift @@ -0,0 +1,60 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import OSLog + +// Internal wire types for the OTP 2.x GTFS GraphQL API `vehicleRentalsByBbox` query. +// The public rental models decode directly off the wire (their field names match the +// GraphQL schema), so only the payload wrappers are private. + +struct GraphQLRentalsData: Decodable { + let vehicleRentalsByBbox: [LenientRentalPlace]? + + /// The decoded rentals, with unrecognized union members dropped. + var rentals: [VehicleRental] { + vehicleRentalsByBbox?.compactMap(\.rental) ?? [] + } +} + +/// Wraps one `RentalPlace` union element, tolerating unknown `__typename`s. +/// +/// A union member OTP adds later must degrade to a skipped entry (logged), never abort +/// the surrounding multi-thousand-entity decode — the same fail-open convention as +/// `VehicleFormFactor`. Malformed entities of a *known* type still throw, so real +/// decode bugs surface in tests instead of vanishing. +struct LenientRentalPlace: Decodable { + let rental: VehicleRental? + + private enum TypeNameCodingKeys: String, CodingKey { + case typename = "__typename" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: TypeNameCodingKeys.self) + let typename = try container.decode(String.self, forKey: .typename) + + switch typename { + case "VehicleRentalStation": + rental = .station(try VehicleRentalStation(from: decoder)) + case "RentalVehicle": + rental = .vehicle(try RentalVehicle(from: decoder)) + default: + Logger.main.warning("Skipping unrecognized RentalPlace __typename: \(typename)") + rental = nil + } + } +} diff --git a/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift b/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift new file mode 100644 index 0000000..755766c --- /dev/null +++ b/OTPKit/Sources/OTPKit/Network/VehicleRentalService.swift @@ -0,0 +1,39 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// The capability to fetch vehicle rental (bikeshare/micromobility) data. +/// +/// Separate from `APIService` because not every backend supports rentals — +/// `RestAPIService` (OTP 1.x) does not. Conformance doubles as the capability +/// flag: hosts check `apiService is VehicleRentalService` to decide whether +/// rental features can work at all. +public protocol VehicleRentalService { + /// Fetches rental stations and free-floating vehicles in a bounding box. + /// + /// - Parameters: + /// - boundingBox: The geographic area to search. Keep it small: the OTP + /// `vehicleRentalsByBbox` query has no server-side result limit, and a + /// metro-sized box can return over 12,000 entities. + /// - formFactors: When non-nil, only entities matching these form factors + /// are returned. Entities without typed form-factor data are included + /// (fail-open) so sparse feeds don't disappear. + func fetchVehicleRentals( + in boundingBox: VehicleRentalBoundingBox, + formFactors: Set? + ) async throws -> VehicleRentalFetchResult +} diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index d2b2e28..cedf53f 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -112,8 +112,9 @@ public class TripPlannerViewModel: ObservableObject { self.mapCoordinator = mapCoordinator self.notificationCenter = notificationCenter - // Set the first enabled transport mode as default, fallback to transit - self.selectedTransportMode = config.enabledTransportModes.first ?? .transit + // Set the first *available* transport mode as default, fallback to transit. + // (Static helper because computed properties aren't usable until init completes.) + self.selectedTransportMode = Self.defaultTransportMode(config: config, apiService: apiService) // Load saved trip options from UserDefaults if let savedOptions = UserDefaultsServices.shared.loadTripOptions() { @@ -151,9 +152,25 @@ public class TripPlannerViewModel: ObservableObject { selectedOrigin != nil && selectedDestination != nil } - /// Available transport modes from configuration - var enabledTransportModes: [TransportMode] { - config.enabledTransportModes + /// Transport modes the UI should offer: the configured modes, minus any the + /// injected API service cannot support. `.bikeRental` is part of the GraphQL-era + /// rental feature set (browse layer + rental trip modes ship together), so it is + /// hidden unless the service provides vehicle rental support — a REST-only host + /// never sees rental UI. This is the single source of truth for mode lists; + /// read `config.enabledTransportModes` only for raw configuration. + var availableTransportModes: [TransportMode] { + config.enabledTransportModes.filter { Self.isModeAvailable($0, apiService: apiService) } + } + + /// Whether the API service can actually plan trips for the given mode. + private static func isModeAvailable(_ mode: TransportMode, apiService: APIService) -> Bool { + mode != .bikeRental || apiService is VehicleRentalService + } + + /// The default selected mode: the first capability-available configured mode. + /// Must agree with `availableTransportModes` so the default is always offerable. + private static func defaultTransportMode(config: OTPConfiguration, apiService: APIService) -> TransportMode { + config.enabledTransportModes.first { isModeAvailable($0, apiService: apiService) } ?? .transit } /// All available itineraries from the current trip plan response @@ -387,7 +404,7 @@ public class TripPlannerViewModel: ObservableObject { isLoading = false // Reset to default transport mode - selectedTransportMode = config.enabledTransportModes.first ?? .transit + selectedTransportMode = Self.defaultTransportMode(config: config, apiService: apiService) // Reset time preferences (not persisted) timePreference = .leaveNow diff --git a/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings index cfe957e..580d765 100644 --- a/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "المشي"; "transport_mode.bike" = "الدراجة"; "transport_mode.car" = "السيارة"; +"transport_mode.bike_rental" = "دراجات مشتركة"; + +"rental.vehicle_type.bike" = "دراجة"; +"rental.vehicle_type.ebike" = "دراجة كهربائية"; +"rental.vehicle_type.scooter" = "سكوتر"; +"rental.vehicle_type.car" = "سيارة"; +"rental.vehicle_type.moped" = "دراجة نارية صغيرة"; +"rental.vehicle_type.vehicle" = "مركبة"; /* Walking Directions */ "direction.depart" = "انطلق"; diff --git a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings index fdb65a7..30e8934 100644 --- a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings @@ -217,6 +217,14 @@ "transport_mode.walk" = "Walk"; "transport_mode.bike" = "Bike"; "transport_mode.car" = "Car"; +"transport_mode.bike_rental" = "Bike Rental"; + +"rental.vehicle_type.bike" = "bike"; +"rental.vehicle_type.ebike" = "e-bike"; +"rental.vehicle_type.scooter" = "scooter"; +"rental.vehicle_type.car" = "car"; +"rental.vehicle_type.moped" = "moped"; +"rental.vehicle_type.vehicle" = "vehicle"; /* Walking Directions */ "direction.depart" = "Head out"; diff --git a/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings index f8c22be..e3363e0 100644 --- a/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "A pie"; "transport_mode.bike" = "Bicicleta"; "transport_mode.car" = "Coche"; +"transport_mode.bike_rental" = "Bicicleta compartida"; + +"rental.vehicle_type.bike" = "bici"; +"rental.vehicle_type.ebike" = "bici eléctrica"; +"rental.vehicle_type.scooter" = "patinete"; +"rental.vehicle_type.car" = "coche"; +"rental.vehicle_type.moped" = "ciclomotor"; +"rental.vehicle_type.vehicle" = "vehículo"; /* Walking Directions */ "direction.depart" = "Sal"; diff --git a/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings index c70f8ca..e53e84a 100644 --- a/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "Lakad"; "transport_mode.bike" = "Bisikleta"; "transport_mode.car" = "Kotse"; +"transport_mode.bike_rental" = "Rentahan ng Bisikleta"; + +"rental.vehicle_type.bike" = "bisikleta"; +"rental.vehicle_type.ebike" = "e-bike"; +"rental.vehicle_type.scooter" = "scooter"; +"rental.vehicle_type.car" = "kotse"; +"rental.vehicle_type.moped" = "moped"; +"rental.vehicle_type.vehicle" = "sasakyan"; /* Walking Directions */ "direction.depart" = "Umalis"; diff --git a/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings index 467c066..58d6f07 100644 --- a/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "À pied"; "transport_mode.bike" = "Vélo"; "transport_mode.car" = "Voiture"; +"transport_mode.bike_rental" = "Vélo en libre-service"; + +"rental.vehicle_type.bike" = "vélo"; +"rental.vehicle_type.ebike" = "vélo électrique"; +"rental.vehicle_type.scooter" = "trottinette"; +"rental.vehicle_type.car" = "voiture"; +"rental.vehicle_type.moped" = "cyclomoteur"; +"rental.vehicle_type.vehicle" = "véhicule"; /* Walking Directions */ "direction.depart" = "Partez"; diff --git a/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings index acc0894..d9a1a26 100644 --- a/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "A piedi"; "transport_mode.bike" = "Bicicletta"; "transport_mode.car" = "Auto"; +"transport_mode.bike_rental" = "Bike sharing"; + +"rental.vehicle_type.bike" = "bici"; +"rental.vehicle_type.ebike" = "bici elettrica"; +"rental.vehicle_type.scooter" = "monopattino"; +"rental.vehicle_type.car" = "auto"; +"rental.vehicle_type.moped" = "ciclomotore"; +"rental.vehicle_type.vehicle" = "veicolo"; /* Walking Directions */ "direction.depart" = "Parti"; diff --git a/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings index f91fc30..dc9b9fd 100644 --- a/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "도보"; "transport_mode.bike" = "자전거"; "transport_mode.car" = "자동차"; +"transport_mode.bike_rental" = "공유 자전거"; + +"rental.vehicle_type.bike" = "자전거"; +"rental.vehicle_type.ebike" = "전기 자전거"; +"rental.vehicle_type.scooter" = "킥보드"; +"rental.vehicle_type.car" = "자동차"; +"rental.vehicle_type.moped" = "모페드"; +"rental.vehicle_type.vehicle" = "차량"; /* Walking Directions */ "direction.depart" = "출발"; diff --git a/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings index e0ce7ad..c431058 100644 --- a/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "Pieszo"; "transport_mode.bike" = "Rower"; "transport_mode.car" = "Samochód"; +"transport_mode.bike_rental" = "Rower miejski"; + +"rental.vehicle_type.bike" = "rower"; +"rental.vehicle_type.ebike" = "rower elektryczny"; +"rental.vehicle_type.scooter" = "hulajnoga"; +"rental.vehicle_type.car" = "samochód"; +"rental.vehicle_type.moped" = "motorower"; +"rental.vehicle_type.vehicle" = "pojazd"; /* Walking Directions */ "direction.depart" = "Wyrusz"; diff --git a/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings index b9be65c..d7904bd 100644 --- a/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "A pé"; "transport_mode.bike" = "Bicicleta"; "transport_mode.car" = "Carro"; +"transport_mode.bike_rental" = "Bicicleta compartilhada"; + +"rental.vehicle_type.bike" = "bicicleta"; +"rental.vehicle_type.ebike" = "bicicleta elétrica"; +"rental.vehicle_type.scooter" = "patinete"; +"rental.vehicle_type.car" = "carro"; +"rental.vehicle_type.moped" = "ciclomotor"; +"rental.vehicle_type.vehicle" = "veículo"; /* Walking Directions */ "direction.depart" = "Siga"; diff --git a/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings index 80d6549..79f3dd1 100644 --- a/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "Пешком"; "transport_mode.bike" = "Велосипед"; "transport_mode.car" = "Автомобиль"; +"transport_mode.bike_rental" = "Велопрокат"; + +"rental.vehicle_type.bike" = "велосипед"; +"rental.vehicle_type.ebike" = "электровелосипед"; +"rental.vehicle_type.scooter" = "самокат"; +"rental.vehicle_type.car" = "автомобиль"; +"rental.vehicle_type.moped" = "мопед"; +"rental.vehicle_type.vehicle" = "транспорт"; /* Walking Directions */ "direction.depart" = "Начните движение"; diff --git a/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings index 01cd179..b2b463b 100644 --- a/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "Đi bộ"; "transport_mode.bike" = "Xe đạp"; "transport_mode.car" = "Ô tô"; +"transport_mode.bike_rental" = "Xe đạp công cộng"; + +"rental.vehicle_type.bike" = "xe đạp"; +"rental.vehicle_type.ebike" = "xe đạp điện"; +"rental.vehicle_type.scooter" = "xe scooter"; +"rental.vehicle_type.car" = "ô tô"; +"rental.vehicle_type.moped" = "xe máy nhỏ"; +"rental.vehicle_type.vehicle" = "phương tiện"; /* Walking Directions */ "direction.depart" = "Xuất phát"; diff --git a/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings index af22cc3..ad6ba17 100644 --- a/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings @@ -215,6 +215,14 @@ "transport_mode.walk" = "步行"; "transport_mode.bike" = "骑行"; "transport_mode.car" = "驾车"; +"transport_mode.bike_rental" = "共享单车"; + +"rental.vehicle_type.bike" = "单车"; +"rental.vehicle_type.ebike" = "电动单车"; +"rental.vehicle_type.scooter" = "滑板车"; +"rental.vehicle_type.car" = "汽车"; +"rental.vehicle_type.moped" = "轻便摩托车"; +"rental.vehicle_type.vehicle" = "车辆"; /* Walking Directions */ "direction.depart" = "出发"; diff --git a/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings index b7e35b6..82fff66 100644 --- a/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings @@ -216,6 +216,14 @@ "transport_mode.walk" = "步行"; "transport_mode.bike" = "自行車"; "transport_mode.car" = "開車"; +"transport_mode.bike_rental" = "共享單車"; + +"rental.vehicle_type.bike" = "單車"; +"rental.vehicle_type.ebike" = "電動單車"; +"rental.vehicle_type.scooter" = "滑板車"; +"rental.vehicle_type.car" = "汽車"; +"rental.vehicle_type.moped" = "輕型機車"; +"rental.vehicle_type.vehicle" = "車輛"; /* Walking Directions */ "direction.depart" = "出發"; diff --git a/OTPKit/Tests/Fixtures/graphql_plan_rental.json b/OTPKit/Tests/Fixtures/graphql_plan_rental.json new file mode 100644 index 0000000..e864573 --- /dev/null +++ b/OTPKit/Tests/Fixtures/graphql_plan_rental.json @@ -0,0 +1,125 @@ +{ + "data": { + "plan": { + "date": 1785340800000, + "from": { + "name": "Origin", + "lon": -122.3331, + "lat": 47.6097, + "vertexType": "NORMAL" + }, + "to": { + "name": "Destination", + "lon": -122.3493, + "lat": 47.6205, + "vertexType": "NORMAL" + }, + "routingErrors": [], + "itineraries": [ + { + "duration": 932, + "startTime": 1785341233000, + "endTime": 1785342165000, + "walkTime": 233, + "waitingTime": 0, + "walkDistance": 305.2, + "elevationLost": 0.0, + "elevationGained": 0.0, + "numberOfTransfers": 0, + "legs": [ + { + "startTime": 1785341233000, + "endTime": 1785341466000, + "mode": "WALK", + "route": null, + "from": { + "name": "Origin", + "lon": -122.3331, + "lat": 47.6097, + "vertexType": "NORMAL", + "stop": null, + "vehicleRentalStation": null, + "rentalVehicle": null + }, + "to": { + "name": "Default vehicle type", + "lon": -122.334764, + "lat": 47.609818, + "vertexType": "BIKESHARE", + "stop": null, + "vehicleRentalStation": null, + "rentalVehicle": { + "vehicleId": "lime_seattle:9e18440a-e282-4ac5-94d0-2659f6311bed" + } + }, + "legGeometry": { + "points": "eyu`Hnn~aVBmA?_A", + "length": 3 + }, + "distance": 305.2, + "transitLeg": false, + "rentedBike": false, + "duration": 233.0, + "realTime": false, + "departureDelay": 0, + "arrivalDelay": 0, + "headsign": null, + "intermediatePlaces": null, + "steps": [ + { + "distance": 305.2, + "streetName": "Pine Street", + "relativeDirection": "DEPART", + "lon": -122.3331, + "lat": 47.6097 + } + ] + }, + { + "startTime": 1785341466000, + "endTime": 1785342165000, + "mode": "BICYCLE", + "route": null, + "from": { + "name": "Default vehicle type", + "lon": -122.334764, + "lat": 47.609818, + "vertexType": "BIKESHARE", + "stop": null, + "vehicleRentalStation": null, + "rentalVehicle": { + "vehicleId": "lime_seattle:9e18440a-e282-4ac5-94d0-2659f6311bed" + } + }, + "to": { + "name": "Pine St & 9th Ave", + "lon": -122.3493, + "lat": 47.6205, + "vertexType": "BIKESHARE", + "stop": null, + "vehicleRentalStation": { + "stationId": "pronto:BT-01" + }, + "rentalVehicle": null + }, + "legGeometry": { + "points": "kzu`Hpt~aVoBnCiAtB", + "length": 3 + }, + "distance": 1830.4, + "transitLeg": false, + "rentedBike": true, + "duration": 699.0, + "realTime": false, + "departureDelay": 0, + "arrivalDelay": 0, + "headsign": null, + "intermediatePlaces": null, + "steps": [] + } + ] + } + ] + } + } +} diff --git a/OTPKit/Tests/Fixtures/graphql_rentals_mixed.json b/OTPKit/Tests/Fixtures/graphql_rentals_mixed.json new file mode 100644 index 0000000..ab8e8ed --- /dev/null +++ b/OTPKit/Tests/Fixtures/graphql_rentals_mixed.json @@ -0,0 +1,117 @@ +{ + "data": { + "vehicleRentalsByBbox": [ + { + "__typename": "VehicleRentalStation", + "stationId": "pronto:BT-01", + "name": "Pine St & 9th Ave", + "lat": 47.6134, + "lon": -122.3325, + "vehiclesAvailable": 3, + "spacesAvailable": 15, + "allowPickupNow": true, + "allowDropoffNow": true, + "operative": true, + "rentalNetwork": { + "networkId": "pronto", + "url": "https://www.prontocycleshare.com" + }, + "rentalUris": { + "ios": "https://pronto.example.com/stations/BT-01", + "android": null, + "web": "https://pronto.example.com/stations/BT-01" + }, + "availableVehicles": { + "total": 3, + "byType": [ + { + "count": 3, + "vehicleType": { + "formFactor": "BICYCLE" + } + } + ] + }, + "availableSpaces": { + "total": 15 + } + }, + { + "__typename": "RentalVehicle", + "vehicleId": "lime_seattle:9f8b7460-b06e-4e2e-bbd4-01b40bbdbc0d", + "name": "Default vehicle type", + "lat": 47.6101, + "lon": -122.3358, + "allowPickupNow": true, + "operative": true, + "rentalNetwork": { + "networkId": "lime_seattle", + "url": null + }, + "rentalUris": null, + "vehicleType": { + "formFactor": "BICYCLE", + "propulsionType": "ELECTRIC_ASSIST" + }, + "fuel": { + "percent": null, + "range": 26602 + } + }, + { + "__typename": "RentalVehicle", + "vehicleId": "lime_seattle:2a919739-89e2-48e4-a3d8-dfbd2a29f674", + "name": "Default vehicle type", + "lat": 47.6088, + "lon": -122.3402, + "allowPickupNow": true, + "operative": true, + "rentalNetwork": { + "networkId": "lime_seattle", + "url": null + }, + "rentalUris": null, + "vehicleType": { + "formFactor": "SCOOTER_STANDING", + "propulsionType": "ELECTRIC" + }, + "fuel": { + "percent": 0.62, + "range": 18926 + } + }, + { + "__typename": "RentalVehicle", + "vehicleId": "future_mobility:hoverboard-1", + "name": "Hoverboard", + "lat": 47.6144, + "lon": -122.3287, + "allowPickupNow": true, + "operative": false, + "rentalNetwork": { + "networkId": "future_mobility", + "url": null + }, + "rentalUris": null, + "vehicleType": { + "formFactor": "HOVERBOARD", + "propulsionType": "ELECTRIC" + }, + "fuel": null + }, + { + "__typename": "RentalVehicle", + "vehicleId": "mystery_wheels:untyped-1", + "name": "Untyped vehicle", + "lat": 47.6152, + "lon": -122.3311, + "allowPickupNow": null, + "operative": null, + "rentalNetwork": null, + "rentalUris": null, + "vehicleType": null, + "fuel": null + } + ] + } +} diff --git a/OTPKit/Tests/Fixtures/graphql_rentals_partial_error.json b/OTPKit/Tests/Fixtures/graphql_rentals_partial_error.json new file mode 100644 index 0000000..945f3ac --- /dev/null +++ b/OTPKit/Tests/Fixtures/graphql_rentals_partial_error.json @@ -0,0 +1,33 @@ +{ + "data": { + "vehicleRentalsByBbox": [ + { + "__typename": "RentalVehicle", + "vehicleId": "lime_seattle:9f8b7460-b06e-4e2e-bbd4-01b40bbdbc0d", + "name": "Default vehicle type", + "lat": 47.6101, + "lon": -122.3358, + "allowPickupNow": true, + "operative": true, + "rentalNetwork": { + "networkId": "lime_seattle", + "url": null + }, + "rentalUris": null, + "vehicleType": { + "formFactor": "BICYCLE", + "propulsionType": "ELECTRIC_ASSIST" + }, + "fuel": { + "percent": null, + "range": 26602 + } + } + ] + }, + "errors": [ + { + "message": "Exception while fetching data (/vehicleRentalsByBbox) : feed bird_seattle timed out" + } + ] +} diff --git a/OTPKit/Tests/GraphQLAPIServiceTests.swift b/OTPKit/Tests/GraphQLAPIServiceTests.swift index 5988214..22b4390 100644 --- a/OTPKit/Tests/GraphQLAPIServiceTests.swift +++ b/OTPKit/Tests/GraphQLAPIServiceTests.swift @@ -94,6 +94,22 @@ class GraphQLAPIServiceTests: OTPTestCase { XCTAssertEqual(modes.map { $0["mode"] as? String }, ["BICYCLE", "WALK"]) } + func testFetchPlanSendsBikeRentalQualifier() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_plan_success.json")) + + _ = try await service.fetchPlan(createTripPlanRequest(transportModes: [.bikeRental, .walk])) + + let request = try XCTUnwrap(mockDataLoader.lastRequest) + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let variables = try XCTUnwrap(payload["variables"] as? [String: Any]) + let modes = try XCTUnwrap(variables["transportModes"] as? [[String: Any]]) + // BICYCLE_RENT is a REST-only token; GraphQL expresses rentals as a qualified BICYCLE. + XCTAssertEqual(modes.map { $0["mode"] as? String }, ["BICYCLE", "WALK"]) + XCTAssertEqual(modes[0]["qualifier"] as? String, "RENT") + XCTAssertNil(modes[1]["qualifier"]) + } + // MARK: - Response Mapping func testFetchPlanMapsItineraries() async throws { @@ -178,6 +194,185 @@ class GraphQLAPIServiceTests: OTPTestCase { XCTAssertEqual(params.wheelchair, "false") } + func testFetchPlanMapsRentalLegs() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_plan_rental.json")) + + let response = try await service.fetchPlan(createTripPlanRequest(transportModes: [.bikeRental, .walk])) + + let legs = try XCTUnwrap(response.plan?.itineraries.first?.legs) + XCTAssertEqual(legs.count, 2) + + let walkLeg = legs[0] + XCTAssertEqual(walkLeg.mode, "WALK") + XCTAssertEqual(walkLeg.rentedBike, false) + XCTAssertNil(walkLeg.from.bikeShareId) + // The walk leg ends at the free-floating vehicle being picked up. + XCTAssertEqual(walkLeg.to.bikeShareId, "lime_seattle:9e18440a-e282-4ac5-94d0-2659f6311bed") + + let rideLeg = legs[1] + XCTAssertEqual(rideLeg.mode, "BICYCLE") + XCTAssertEqual(rideLeg.rentedBike, true) + XCTAssertEqual(rideLeg.from.bikeShareId, "lime_seattle:9e18440a-e282-4ac5-94d0-2659f6311bed") + // Docked dropoff maps the station id into the same field. + XCTAssertEqual(rideLeg.to.bikeShareId, "pronto:BT-01") + } + + // MARK: - Vehicle Rentals + + func testFetchVehicleRentalsSendsRequest() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_mixed.json")) + + _ = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + + let request = try XCTUnwrap(mockDataLoader.lastRequest) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://sound-transit-otp.ibi-transit.com/otp/gtfs/v1") + + let body = try XCTUnwrap(request.httpBody) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + let query = try XCTUnwrap(payload["query"] as? String) + XCTAssertTrue(query.contains("vehicleRentalsByBbox(")) + + let variables = try XCTUnwrap(payload["variables"] as? [String: Any]) + XCTAssertEqual(variables["minLat"] as? Double, 47.5) + XCTAssertEqual(variables["maxLat"] as? Double, 47.7) + XCTAssertEqual(variables["minLon"] as? Double, -122.4) + XCTAssertEqual(variables["maxLon"] as? Double, -122.2) + } + + func testFetchVehicleRentalsMapsStationsAndVehicles() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_mixed.json")) + + let result = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + + XCTAssertEqual(result.rentals.count, 5) + XCTAssertTrue(result.partialErrors.isEmpty) + + guard case .station(let station) = result.rentals[0] else { + return XCTFail("Expected first entity to be a station") + } + XCTAssertEqual(station.stationId, "pronto:BT-01") + XCTAssertEqual(station.name, "Pine St & 9th Ave") + XCTAssertEqual(station.vehiclesAvailableCount, 3) + XCTAssertEqual(station.docksAvailableCount, 15) + XCTAssertTrue(station.isOperative) + XCTAssertEqual(station.rentalUris?.ios, "https://pronto.example.com/stations/BT-01") + + guard case .vehicle(let vehicle) = result.rentals[1] else { + return XCTFail("Expected second entity to be a vehicle") + } + XCTAssertEqual(vehicle.vehicleId, "lime_seattle:9f8b7460-b06e-4e2e-bbd4-01b40bbdbc0d") + XCTAssertEqual(vehicle.vehicleType?.formFactor, .bicycle) + // Battery is absent on the live Seattle feed; range is the reliable stat. + XCTAssertNil(vehicle.fuel?.percent) + XCTAssertEqual(vehicle.fuel?.range, 26602) + XCTAssertNil(vehicle.rentalUris) + } + + func testFetchVehicleRentalsUnknownFormFactorDecodesAsOther() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_mixed.json")) + + let result = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + + guard case .vehicle(let hoverboard) = result.rentals[3] else { + return XCTFail("Expected fourth entity to be a vehicle") + } + // A form factor OTP adds later must degrade to .other, never fail the whole decode. + XCTAssertEqual(hoverboard.vehicleType?.formFactor, .other) + XCTAssertFalse(hoverboard.isOperative) + } + + func testFetchVehicleRentalsFiltersBicycles() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_mixed.json")) + + let result = try await service.fetchVehicleRentals( + in: seattleBoundingBox, + formFactors: [.bicycle, .cargoBicycle] + ) + + // Station stocks bicycles, one vehicle is a bicycle, and the untyped vehicle + // is included fail-open. The scooter and the unknown form factor are excluded. + XCTAssertEqual(result.rentals.map(\.id), [ + "pronto:BT-01", + "lime_seattle:9f8b7460-b06e-4e2e-bbd4-01b40bbdbc0d", + "mystery_wheels:untyped-1" + ]) + } + + func testFetchVehicleRentalsFiltersScooters() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_mixed.json")) + + let result = try await service.fetchVehicleRentals( + in: seattleBoundingBox, + formFactors: [.scooter, .scooterSeated, .scooterStanding] + ) + + // The bicycle-only station is excluded; the untyped vehicle is fail-open included. + XCTAssertEqual(result.rentals.map(\.id), [ + "lime_seattle:2a919739-89e2-48e4-a3d8-dfbd2a29f674", + "mystery_wheels:untyped-1" + ]) + } + + func testFetchVehicleRentalsPartialSuccessReturnsDataAndErrors() async throws { + mockDataLoader.mockResponse(data: Fixtures.loadData(file: "graphql_rentals_partial_error.json")) + + let result = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + + XCTAssertEqual(result.rentals.count, 1) + XCTAssertEqual(result.partialErrors.count, 1) + XCTAssertTrue(result.partialErrors[0].contains("timed out")) + } + + func testFetchVehicleRentalsThrowsOnTopLevelGraphQLError() async throws { + let errorJSON = """ + {"errors":[{"message":"Validation error: unknown field"}]} + """ + mockDataLoader.mockResponse(data: Data(errorJSON.utf8)) + + do { + _ = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + XCTFail("Expected fetchVehicleRentals to throw") + } catch let error as OTPKitError { + guard case .apiError(let message, _) = error else { + return XCTFail("Expected apiError, got \(error)") + } + XCTAssertTrue(message.contains("Validation error")) + } + } + + func testFetchVehicleRentalsSkipsUnknownTypename() async throws { + let json = """ + {"data":{"vehicleRentalsByBbox":[ + {"__typename":"RentalDrone","droneId":"x"}, + {"__typename":"RentalVehicle","vehicleId":"lime_seattle:abc","name":"Default vehicle type", + "lat":47.61,"lon":-122.33,"allowPickupNow":true,"operative":true, + "rentalNetwork":null,"rentalUris":null,"vehicleType":null,"fuel":null} + ]}} + """ + mockDataLoader.mockResponse(data: Data(json.utf8)) + + let result = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + + // A union member OTP adds later degrades to a skipped entry — it must never + // abort the decode of the thousands of entities around it. + XCTAssertEqual(result.rentals.map(\.id), ["lime_seattle:abc"]) + } + + func testFetchVehicleRentalsThrowsOnHTTPError() async throws { + mockDataLoader.mockResponse(data: Data("{}".utf8), statusCode: 502) + + do { + _ = try await service.fetchVehicleRentals(in: seattleBoundingBox, formFactors: nil) + XCTFail("Expected fetchVehicleRentals to throw") + } catch let error as OTPKitError { + guard case .apiError(_, let statusCode) = error else { + return XCTFail("Expected apiError, got \(error)") + } + XCTAssertEqual(statusCode, 502) + } + } + // MARK: - Error Handling func testFetchPlanMapsRoutingErrors() async throws { @@ -251,6 +446,15 @@ private extension GraphQLAPIServiceTests { static let testDate = DateFormatter.tripDateFormatter.date(from: "05-10-2024")! static let testTime = DateFormatter.tripAPITimeFormatter.date(from: "08:00")! + var seattleBoundingBox: VehicleRentalBoundingBox { + VehicleRentalBoundingBox( + minimumLatitude: 47.5, + maximumLatitude: 47.7, + minimumLongitude: -122.4, + maximumLongitude: -122.2 + ) + } + func createTripPlanRequest(transportModes: [TransportMode] = [.transit, .walk]) -> TripPlanRequest { TestFixtures.makeTripPlanRequest( origin: CLLocationCoordinate2D(latitude: 47.6097, longitude: -122.3331), diff --git a/OTPKit/Tests/Helpers/TestFixtures.swift b/OTPKit/Tests/Helpers/TestFixtures.swift index 943ee57..766cb64 100644 --- a/OTPKit/Tests/Helpers/TestFixtures.swift +++ b/OTPKit/Tests/Helpers/TestFixtures.swift @@ -63,6 +63,22 @@ enum TestFixtures { } } + /// A mock APIService that also advertises vehicle rental capability, for testing + /// capability-gated behavior like `availableTransportModes`. + class MockRentalAPIService: MockAPIService, VehicleRentalService { + var mockRentals: [VehicleRental] = [] + + func fetchVehicleRentals( + in boundingBox: VehicleRentalBoundingBox, + formFactors: Set? + ) async throws -> VehicleRentalFetchResult { + if shouldThrowError { + throw mockError + } + return VehicleRentalFetchResult(rentals: mockRentals) + } + } + // MARK: - Simple Fixture Builders static func makePlace(name: String = "Test") -> Place { diff --git a/OTPKit/Tests/TripPlanRequestTests.swift b/OTPKit/Tests/TripPlanRequestTests.swift index b4372eb..df07b92 100644 --- a/OTPKit/Tests/TripPlanRequestTests.swift +++ b/OTPKit/Tests/TripPlanRequestTests.swift @@ -79,6 +79,20 @@ struct TripPlanRequestTests { // MARK: - transportModesString Tests + @Test("transportModesString spells bike rental as the OTP 1.x REST wire token") + func transportModesStringBikeRental() { + let request = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 0, longitude: 0), + destination: CLLocationCoordinate2D(latitude: 1, longitude: 1), + date: Date(), + time: Date(), + transportModes: [.bikeRental, .walk] + ) + + // REST passes raw values through untouched, so the raw value IS the wire token. + #expect(request.transportModesString == "BICYCLE_RENT,WALK") + } + @Test("transportModesString with single mode") func transportModesStringSingleMode() { let request = TripPlanRequest( diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index edc45d6..7163f01 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -127,11 +127,47 @@ struct TripPlannerViewModelTests { #expect(viewModel.canPlanTrip == true) } - @Test("enabledTransportModes returns config modes") - func enabledTransportModesReturnsConfigModes() { + @Test("availableTransportModes filters bike rental when the service lacks rental support") + func availableTransportModesFiltersBikeRental() { + let viewModel = createViewModel(enabledModes: [.transit, .walk, .bikeRental]) + + #expect(viewModel.availableTransportModes == [.transit, .walk]) + } + + @Test("availableTransportModes keeps bike rental when the service supports rentals") + func availableTransportModesKeepsBikeRentalWithCapableService() { + let viewModel = createViewModel( + enabledModes: [.transit, .walk, .bikeRental], + mockAPIService: TestFixtures.MockRentalAPIService() + ) + + #expect(viewModel.availableTransportModes == [.transit, .walk, .bikeRental]) + } + + @Test("availableTransportModes passes non-rental modes through unchanged") + func availableTransportModesPassesOtherModesThrough() { let viewModel = createViewModel(enabledModes: [.bike, .car]) - #expect(viewModel.enabledTransportModes == [.bike, .car]) + #expect(viewModel.availableTransportModes == [.bike, .car]) + } + + @Test("Default selected mode skips an unavailable rental mode listed first") + func defaultSelectedModeSkipsUnavailableRentalMode() { + // .bikeRental configured first, but the service has no rental support: + // the default selection must agree with what the UI can actually offer. + let viewModel = createViewModel(enabledModes: [.bikeRental, .bike, .walk]) + + #expect(viewModel.selectedTransportMode == .bike) + } + + @Test("Default selected mode honors a rental mode when the service supports it") + func defaultSelectedModeHonorsAvailableRentalMode() { + let viewModel = createViewModel( + enabledModes: [.bikeRental, .walk], + mockAPIService: TestFixtures.MockRentalAPIService() + ) + + #expect(viewModel.selectedTransportMode == .bikeRental) } @Test("itineraries returns empty array when no response") diff --git a/OTPKit/Tests/VehicleRentalTests.swift b/OTPKit/Tests/VehicleRentalTests.swift new file mode 100644 index 0000000..7d0d9f5 --- /dev/null +++ b/OTPKit/Tests/VehicleRentalTests.swift @@ -0,0 +1,201 @@ +// +// VehicleRentalTests.swift +// OTPKitTests +// +// Tests for vehicle rental models: union decoding, form factors, and display labels. +// + +import Foundation +import Testing +@testable import OTPKit + +@Suite("Vehicle Rental Models") +struct VehicleRentalTests { + + // MARK: - VehicleFormFactor + + @Suite("VehicleFormFactor") + struct FormFactorTests { + + @Test("Decodes every OTP FormFactor value") + func decodesKnownValues() throws { + for factor in VehicleFormFactor.allCases { + let decoded = try JSONDecoder().decode( + VehicleFormFactor.self, + from: Data("\"\(factor.rawValue)\"".utf8) + ) + #expect(decoded == factor) + } + } + + @Test("Unknown wire values decode fail-open as .other") + func unknownValueDecodesAsOther() throws { + let decoded = try JSONDecoder().decode( + VehicleFormFactor.self, + from: Data("\"HOVERBOARD\"".utf8) + ) + #expect(decoded == .other) + } + + @Test("Scooter and bicycle groupings cover their variants") + func groupings() { + #expect(VehicleFormFactor.scooter.isScooter) + #expect(VehicleFormFactor.scooterSeated.isScooter) + #expect(VehicleFormFactor.scooterStanding.isScooter) + #expect(!VehicleFormFactor.bicycle.isScooter) + + #expect(VehicleFormFactor.bicycle.isBicycle) + #expect(VehicleFormFactor.cargoBicycle.isBicycle) + #expect(!VehicleFormFactor.scooter.isBicycle) + #expect(!VehicleFormFactor.other.isBicycle) + } + } + + // MARK: - RentalNetwork + + @Suite("RentalNetwork") + struct NetworkTests { + + @Test("Network id humanizes to an operator name") + func displayName() { + #expect(RentalNetwork(networkId: "lime_seattle", url: nil).displayName == "Lime") + #expect(RentalNetwork(networkId: "bird-seattle-washington", url: nil).displayName == "Bird") + #expect(RentalNetwork(networkId: "pronto", url: nil).displayName == "Pronto") + } + } + + // MARK: - Display Labels + + @Suite("displayLabel") + struct DisplayLabelTests { + + @Test("Vehicle label prefers operator + type over the raw name") + func vehicleWithNetwork() { + let rental = VehicleRental.vehicle(makeVehicle( + name: "Default vehicle type", + networkId: "lime_seattle", + formFactor: .bicycle, + propulsionType: "ELECTRIC_ASSIST" + )) + #expect(rental.displayLabel == "Lime e-bike") + } + + @Test("Scooter label uses the scooter type name") + func scooterLabel() { + let rental = VehicleRental.vehicle(makeVehicle( + name: "Default vehicle type", + networkId: "lime_seattle", + formFactor: .scooterStanding, + propulsionType: "ELECTRIC" + )) + #expect(rental.displayLabel == "Lime scooter") + } + + @Test("Placeholder name is never surfaced, even without a network") + func placeholderSuppressed() { + let rental = VehicleRental.vehicle(makeVehicle( + name: "Default vehicle type", + networkId: nil, + formFactor: .bicycle, + propulsionType: "ELECTRIC_ASSIST" + )) + #expect(rental.displayLabel == "E-bike") + } + + @Test("A real feed name is used when no network is available") + func realNameUsed() { + let rental = VehicleRental.vehicle(makeVehicle( + name: "Blue Cruiser 42", + networkId: nil, + formFactor: .bicycle, + propulsionType: "HUMAN" + )) + #expect(rental.displayLabel == "Blue Cruiser 42") + } + + @Test("Station label is the station name") + func stationLabel() { + let station = VehicleRentalStation( + stationId: "pronto:BT-01", + name: "Pine St & 9th Ave", + lat: 47.61, + lon: -122.33, + vehiclesAvailable: 3, + spacesAvailable: 15, + allowPickupNow: true, + allowDropoffNow: true, + operative: true, + rentalNetwork: nil, + rentalUris: nil, + availableVehicles: nil, + availableSpaces: nil + ) + #expect(VehicleRental.station(station).displayLabel == "Pine St & 9th Ave") + } + } + + // MARK: - Union Decoding + + @Suite("Union decoding") + struct UnionDecodingTests { + + @Test("Convenience accessors delegate to the wrapped value") + func convenienceAccessors() throws { + let json = """ + { + "__typename": "RentalVehicle", + "vehicleId": "lime_seattle:abc", + "name": "Default vehicle type", + "lat": 47.61, + "lon": -122.33, + "allowPickupNow": true, + "operative": null, + "rentalNetwork": {"networkId": "lime_seattle", "url": null}, + "rentalUris": null, + "vehicleType": {"formFactor": "BICYCLE", "propulsionType": "ELECTRIC_ASSIST"}, + "fuel": {"percent": null, "range": 20000} + } + """ + let rental = try JSONDecoder().decode(VehicleRental.self, from: Data(json.utf8)) + + #expect(rental.id == "lime_seattle:abc") + #expect(rental.coordinate.latitude == 47.61) + #expect(rental.isOperative) + #expect(rental.batteryPercent == nil) + #expect(rental.rentalUris == nil) + #expect(rental.rentalNetwork?.displayName == "Lime") + } + + @Test("Unknown __typename throws a DecodingError") + func unknownTypenameThrows() { + let json = """ + {"__typename": "RentalDrone", "droneId": "x"} + """ + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(VehicleRental.self, from: Data(json.utf8)) + } + } + } + + // MARK: - Helpers + + private static func makeVehicle( + name: String, + networkId: String?, + formFactor: VehicleFormFactor?, + propulsionType: String? + ) -> RentalVehicle { + RentalVehicle( + vehicleId: "test:1", + name: name, + lat: 47.61, + lon: -122.33, + allowPickupNow: true, + operative: true, + rentalNetwork: networkId.map { RentalNetwork(networkId: $0, url: nil) }, + rentalUris: nil, + vehicleType: formFactor.map { VehicleType(formFactor: $0, propulsionType: propulsionType) }, + fuel: nil + ) + } +}