diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java index 87b92733..c11d13a8 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java @@ -28,6 +28,7 @@ import java.util.stream.Collectors; import static org.opentripplanner.middleware.triptracker.instruction.TripInstruction.NO_INSTRUCTION; +import static org.opentripplanner.middleware.triptracker.instruction.TripInstruction.TRIP_INSTRUCTION_IMMEDIATE_RADIUS; import static org.opentripplanner.middleware.triptracker.instruction.TripInstruction.TRIP_INSTRUCTION_UPCOMING_RADIUS; import static org.opentripplanner.middleware.utils.GeometryUtils.getDistance; import static org.opentripplanner.middleware.utils.GeometryUtils.isPointBetween; @@ -210,6 +211,13 @@ private static boolean isApproachingEndOfLeg(TravelerPosition travelerPosition) return getDistanceToEndOfLeg(travelerPosition) <= TRIP_INSTRUCTION_UPCOMING_RADIUS; } + /** + * Is the traveler at the leg destination. + */ + public static boolean isAtEndOfLeg(TravelerPosition travelerPosition) { + return getDistanceToEndOfLeg(travelerPosition) <= TRIP_INSTRUCTION_IMMEDIATE_RADIUS; + } + /** * Make sure the traveler is on schedule or ahead of schedule (but not too far) to be within an operational window * for the bus service. diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java b/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java index 0f4f6045..8ff353a8 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java @@ -2,6 +2,7 @@ import java.time.Instant; +import static org.opentripplanner.middleware.triptracker.TravelerLocator.isAtEndOfLeg; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsInt; import static org.opentripplanner.middleware.utils.GeometryUtils.getDistanceFromLine; @@ -34,7 +35,12 @@ public enum TripStatus { /** * The traveler has deviated from the trip route. **/ - DEVIATED; + DEVIATED, + + /** + * The traveler has arrived at the trip's final destination. + **/ + COMPLETED; public static final int TRIP_TRACKING_WALK_ON_TRACK_RADIUS = getConfigPropertyAsInt("TRIP_TRACKING_WALK_ON_TRACK_RADIUS", 5); @@ -58,6 +64,9 @@ public enum TripStatus { * Define the trip status based on the traveler's current position compared to expected and nearest points on the trip. */ public static TripStatus getTripStatus(TravelerPosition travelerPosition) { + if (isAtEndOfLeg(travelerPosition) && travelerPosition.nextLeg == null) { + return TripStatus.COMPLETED; + } if (travelerPosition.expectedLeg != null && travelerPosition.legSegmentFromPosition != null && isWithinModeRadius(travelerPosition) diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 6b97e4c5..88ea9b53 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -2772,6 +2772,7 @@ definitions: - "AHEAD_OF_SCHEDULE" - "ENDED" - "DEVIATED" + - "COMPLETED" TrackingResponse: type: "object" properties: diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java index 27b3dc84..30748a07 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java @@ -39,6 +39,7 @@ import org.opentripplanner.middleware.utils.HttpResponseValues; import org.opentripplanner.middleware.utils.JsonUtils; +import java.time.Instant; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -60,8 +61,10 @@ public class TrackedTripControllerTest extends OtpMiddlewareTestEnvironment { private static OtpUser soloOtpUser; private static MonitoredTrip monitoredTrip; + private static MonitoredTrip multiLegMonitoredTrip; private static TrackedJourney trackedJourney; private static Itinerary itinerary; + private static Itinerary multiLegItinerary; private static final String ROUTE_PATH = "api/secure/monitoredtrip/"; private static final String START_TRACKING_TRIP_PATH = ROUTE_PATH + "starttracking"; @@ -81,6 +84,10 @@ public static void setUp() throws Exception { CommonTestUtils.getTestResourceAsString("controllers/api/adair-avenue-to-monroe-drive.json"), Itinerary.class ); + multiLegItinerary = JsonUtils.getPOJOFromJSON( + CommonTestUtils.getTestResourceAsString("controllers/api/27nb-midtown-to-ansley.json"), + Itinerary.class + ); soloOtpUser = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("test-solootpuser")); try { @@ -93,12 +100,19 @@ public static void setUp() throws Exception { } catch (Auth0Exception e) { throw new RuntimeException(e); } - monitoredTrip = new MonitoredTrip(); - monitoredTrip.userId = soloOtpUser.id; - monitoredTrip.itinerary = itinerary; - monitoredTrip.journeyState = new JourneyState(); - monitoredTrip.journeyState.matchingItinerary = itinerary; - Persistence.monitoredTrips.create(monitoredTrip); + + monitoredTrip = createMonitoredTrip(itinerary); + multiLegMonitoredTrip = createMonitoredTrip(multiLegItinerary); + } + + private static MonitoredTrip createMonitoredTrip(Itinerary itin) { + MonitoredTrip trip = new MonitoredTrip(); + trip.userId = soloOtpUser.id; + trip.itinerary = itin; + trip.journeyState = new JourneyState(); + trip.journeyState.matchingItinerary = itin; + Persistence.monitoredTrips.create(trip); + return trip; } @AfterAll @@ -109,6 +123,8 @@ public static void tearDown() throws Exception { if (soloOtpUser != null) soloOtpUser.delete(true); monitoredTrip = Persistence.monitoredTrips.getById(monitoredTrip.id); if (monitoredTrip != null) monitoredTrip.delete(); + multiLegMonitoredTrip = Persistence.monitoredTrips.getById(multiLegMonitoredTrip.id); + if (multiLegMonitoredTrip != null) multiLegMonitoredTrip.delete(); } @AfterEach @@ -178,68 +194,77 @@ void canNotRestartAnOngoingJourney() throws Exception { String jsonPayload = JsonUtils.toJson(createStartTrackingPayload()); - var response = makeRequest( - START_TRACKING_TRIP_PATH, - jsonPayload, - headers, - HttpMethod.POST - ); + // Make two identical requests to start and update a journey. The second one should fail. + for (int i = 0; i < 2; i++) { + var response = makeRequest(START_TRACKING_TRIP_PATH, jsonPayload, headers, HttpMethod.POST); + var startTrackingResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); + + if (i == 0) { + assertEquals(HttpStatus.OK_200, response.status); + trackedJourney = Persistence.trackedJourneys.getById(startTrackingResponse.journeyId); + } else { + assertEquals("A journey of this trip has already been started. End the current journey before starting another.", startTrackingResponse.message); + assertEquals(HttpStatus.FORBIDDEN_403, response.status); + } + } + } - var startTrackingResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); - trackedJourney = Persistence.trackedJourneys.getById(startTrackingResponse.journeyId); - assertEquals(HttpStatus.OK_200, response.status); + @Test + void canStartThenUpdateOngoingJourney() throws Exception { + assumeTrue(IS_END_TO_END); - response = makeRequest( - START_TRACKING_TRIP_PATH, - jsonPayload, - headers, - HttpMethod.POST - ); + Leg firstLeg = itinerary.legs.get(0); + Coordinates coords = new Coordinates(firstLeg.steps.get(0)); + String jsonPayload = JsonUtils.toJson(createTrackPayload(coords)); - startTrackingResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); - assertEquals("A journey of this trip has already been started. End the current journey before starting another.", startTrackingResponse.message); - assertEquals(HttpStatus.FORBIDDEN_403, response.status); + // Make two identical requests to start and update a journey. Record outcomes to see if they are same. + TrackingResponse[] trackResponses = new TrackingResponse[2]; + for (int i = 0; i < 2; i++) { + var response = makeRequest(TRACK_TRIP_PATH, jsonPayload, headers, HttpMethod.POST); + assertEquals(HttpStatus.OK_200, response.status); + + var trackResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); + trackResponses[i] = trackResponse; + assertNotEquals(0, trackResponse.frequencySeconds); + assertNotNull(trackResponse.journeyId); + + if (trackedJourney == null) { + trackedJourney = Persistence.trackedJourneys.getById(trackResponse.journeyId); + } + } + + assertEquals(trackResponses[0].instruction, trackResponses[1].instruction); + assertEquals(trackResponses[0].tripStatus, trackResponses[1].tripStatus); + assertEquals(trackResponses[0].journeyId, trackResponses[1].journeyId); } @ParameterizedTest - @MethodSource("createStartThenUpdateCases") - void canStartThenUpdateOngoingJourney(Coordinates coords, String instruction, String message) throws Exception { + @MethodSource("createInstructionAndStatusCases") + void canGenerateInstructionAndStatus( + MonitoredTrip trip, + Coordinates coords, + String instruction, + TripStatus status, + String message + ) throws Exception { assumeTrue(IS_END_TO_END); - String jsonPayload = JsonUtils.toJson(createTrackPayload(coords)); - - // First request for starting a journey. - var response = makeRequest( - TRACK_TRIP_PATH, - jsonPayload, - headers, - HttpMethod.POST + String jsonPayload = JsonUtils.toJson( + createTrackPayload(trip, coords, Date.from(Instant.ofEpochMilli(trip.itinerary.startTime.getTime() / 1000))) ); + // Make a request to start a journey. + var response = makeRequest(TRACK_TRIP_PATH, jsonPayload, headers, HttpMethod.POST); + assertEquals(HttpStatus.OK_200, response.status); var trackResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); - assertNotEquals(0, trackResponse.frequencySeconds); assertEquals(instruction, trackResponse.instruction, message); + assertEquals(status.name(), trackResponse.tripStatus); assertNotNull(trackResponse.journeyId); trackedJourney = Persistence.trackedJourneys.getById(trackResponse.journeyId); - - // Second request to update a journey - response = makeRequest( - TRACK_TRIP_PATH, - jsonPayload, - headers, - HttpMethod.POST - ); - - assertEquals(HttpStatus.OK_200, response.status); - trackResponse = JsonUtils.getPOJOFromJSON(response.responseBody, TrackingResponse.class); - assertNotEquals(0, trackResponse.frequencySeconds); - assertEquals(instruction, trackResponse.instruction, message); - assertNotNull(trackResponse.journeyId); - assertEquals(trackedJourney.id, trackResponse.journeyId); } - private static Stream createStartThenUpdateCases() { + private static Stream createInstructionAndStatusCases() { final int NORTH_WEST_BEARING = 315; final int NORTH_EAST_BEARING = 45; final int WEST_BEARING = 270; @@ -247,42 +272,84 @@ private static Stream createStartThenUpdateCases() { Leg firstLeg = itinerary.legs.get(0); Coordinates firstStepCoords = new Coordinates(firstLeg.steps.get(0)); Coordinates thirdStepCoords = new Coordinates(firstLeg.steps.get(2)); + Coordinates destinationCoords = new Coordinates(firstLeg.to); + + Leg multiItinFirstLeg = multiLegItinerary.legs.get(0); + Coordinates multiItinFirstLegDestCoords = new Coordinates(multiItinFirstLeg.to); + Leg multiItinLastLeg = multiLegItinerary.legs.get(multiLegItinerary.legs.size() - 1); + Coordinates multiItinLastLegDestCoords = new Coordinates(multiItinLastLeg.to); return Stream.of( Arguments.of( + monitoredTrip, createPoint(firstStepCoords, 1, NORTH_EAST_BEARING), "IMMEDIATE: Head WEST on Adair Avenue Northeast", + TripStatus.ON_SCHEDULE, "Coords near first step should produce relevant instruction" ), Arguments.of( + monitoredTrip, createPoint(firstStepCoords, 4, NORTH_EAST_BEARING), "UPCOMING: Head WEST on Adair Avenue Northeast", - "Coords near first step should produce relevant instruction" + TripStatus.DEVIATED, + "Coords deviated but near first step should produce relevant instruction" ), Arguments.of( + monitoredTrip, createPoint(firstStepCoords, 30, NORTH_EAST_BEARING), "Head to Adair Avenue Northeast", + TripStatus.DEVIATED, "Deviated coords near first step should produce instruction to head to first step #1" ), Arguments.of( + monitoredTrip, createPoint(firstStepCoords, 15, NORTH_WEST_BEARING), "Head to Adair Avenue Northeast", + TripStatus.DEVIATED, "Deviated coords near first step should produce instruction to head to first step #2" ), Arguments.of( + monitoredTrip, createPoint(firstStepCoords, 20, WEST_BEARING), NO_INSTRUCTION, + TripStatus.ON_SCHEDULE, "Coords along a step should produce no instruction" ), Arguments.of( + monitoredTrip, thirdStepCoords, "IMMEDIATE: LEFT on Ponce de Leon Place Northeast", + TripStatus.AHEAD_OF_SCHEDULE, "Coords near a not-first step should produce relevant instruction" ), Arguments.of( + monitoredTrip, createPoint(thirdStepCoords, 30, NORTH_WEST_BEARING), "Head to Ponce de Leon Place Northeast", + TripStatus.DEVIATED, "Deviated coords near a not-first step should produce instruction to head to step" + ), + Arguments.of( + monitoredTrip, + createPoint(destinationCoords, 1, NORTH_WEST_BEARING), + "ARRIVED: Monroe Dr NE at Cooledge Ave NE", + TripStatus.COMPLETED, + "Instructions for destination coordinate" + ), + Arguments.of( + multiLegMonitoredTrip, + createPoint(multiItinFirstLegDestCoords, 1.5, WEST_BEARING), + // Time is in US Pacific time zone (instead of US Eastern) by configuration for other E2E tests. + "Wait 6 minutes for your bus, route 27, scheduled at 9:18 AM, on time", + TripStatus.AHEAD_OF_SCHEDULE, + "Arriving ahead of schedule to a bus stop at the end of first leg." + ), + Arguments.of( + multiLegMonitoredTrip, + createPoint(multiItinLastLegDestCoords, 1, NORTH_WEST_BEARING), + "ARRIVED: Ansley Mall Pet Shop", + TripStatus.COMPLETED, + "Instructions for destination coordinate of multi-leg trip" ) ); } @@ -409,15 +476,23 @@ private UpdatedTrackingPayload createUpdateTrackingPayload(String journeyId) { return payload; } - private TrackPayload createTrackPayload(List locations) { + private TrackPayload createTrackPayload(MonitoredTrip trip, List locations) { var payload = new TrackPayload(); - payload.tripId = monitoredTrip.id; + payload.tripId = trip.id; payload.locations = locations; return payload; } private TrackPayload createTrackPayload(Coordinates coords) { - return createTrackPayload(List.of(new TrackingLocation(getDateAndConvertToSeconds(), coords.lat, coords.lon))); + return createTrackPayload(monitoredTrip, coords); + } + + private TrackPayload createTrackPayload(MonitoredTrip trip, Coordinates coords) { + return createTrackPayload(trip, coords, getDateAndConvertToSeconds()); + } + + private TrackPayload createTrackPayload(MonitoredTrip trip, Coordinates coords, Date date) { + return createTrackPayload(trip, List.of(new TrackingLocation(date, coords.lat, coords.lon))); } private EndTrackingPayload createEndTrackingPayload(String journeyId) { diff --git a/src/test/resources/org/opentripplanner/middleware/controllers/api/27nb-midtown-to-ansley.json b/src/test/resources/org/opentripplanner/middleware/controllers/api/27nb-midtown-to-ansley.json index c577b6e0..a646f0de 100644 --- a/src/test/resources/org/opentripplanner/middleware/controllers/api/27nb-midtown-to-ansley.json +++ b/src/test/resources/org/opentripplanner/middleware/controllers/api/27nb-midtown-to-ansley.json @@ -225,7 +225,7 @@ "stopCode": "213258" }, "to": { - "name": "33.79868, -84.37128", + "name": "Ansley Mall Pet Shop", "lon": -84.371285, "lat": 33.798675, "vertexType": "NORMAL",