diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/AddedTripBuilder.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/AddedTripBuilder.java index 7255c7adff2..ea7f2ca17bf 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/AddedTripBuilder.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/AddedTripBuilder.java @@ -26,7 +26,6 @@ import org.opentripplanner.transit.model.network.TripPattern; import org.opentripplanner.transit.model.organization.Agency; import org.opentripplanner.transit.model.organization.Operator; -import org.opentripplanner.transit.model.site.RegularStop; import org.opentripplanner.transit.model.timetable.RealTimeState; import org.opentripplanner.transit.model.timetable.RealTimeTripTimes; import org.opentripplanner.transit.model.timetable.Trip; @@ -35,8 +34,6 @@ import org.opentripplanner.transit.service.TransitEditorService; import org.opentripplanner.updater.spi.DataValidationExceptionMapper; import org.opentripplanner.updater.spi.UpdateError; -import org.opentripplanner.updater.trip.siri.mapping.PickDropMapper; -import org.opentripplanner.utils.time.ServiceDateUtils; import org.rutebanken.netex.model.BusSubmodeEnumeration; import org.rutebanken.netex.model.RailSubmodeEnumeration; import org.slf4j.Logger; @@ -69,6 +66,7 @@ class AddedTripBuilder { private final String shortName; private final String headsign; private final List replacedTrips; + private final StopTimesMapper stopTimesMapper; AddedTripBuilder( EstimatedVehicleJourney estimatedVehicleJourney, @@ -120,6 +118,7 @@ class AddedTripBuilder { timeZone = transitService.getTimeZone(); replacedTrips = getReplacedVehicleJourneys(estimatedVehicleJourney); + stopTimesMapper = new StopTimesMapper(entityResolver, timeZone); } AddedTripBuilder( @@ -161,6 +160,7 @@ class AddedTripBuilder { this.headsign = headsign; this.replacedTrips = replacedTrips; this.dataSource = dataSource; + stopTimesMapper = new StopTimesMapper(entityResolver, timeZone); } Result build() { @@ -196,7 +196,7 @@ Result build() { // Create the "scheduled version" of the trip var aimedStopTimes = new ArrayList(); for (int stopSequence = 0; stopSequence < calls.size(); stopSequence++) { - StopTime stopTime = createStopTime( + StopTime stopTime = stopTimesMapper.createStopTime( trip, departureDate, stopSequence, @@ -341,72 +341,6 @@ private Trip createTrip(Route route, FeedScopedId calServiceId) { return tripBuilder.build(); } - /** - * Map the call to a StopTime or return null if the stop cannot be found in the site repository. - */ - private StopTime createStopTime( - Trip trip, - ZonedDateTime departureDate, - int stopSequence, - CallWrapper call, - boolean isFirstStop, - boolean isLastStop - ) { - RegularStop stop = entityResolver.resolveQuay(call.getStopPointRef()); - if (stop == null) { - return null; - } - - StopTime stopTime = new StopTime(); - stopTime.setStopSequence(stopSequence); - stopTime.setTrip(trip); - stopTime.setStop(stop); - - // Fallback to other time, if one doesn't exist - var aimedArrivalTime = call.getAimedArrivalTime() != null - ? call.getAimedArrivalTime() - : call.getAimedDepartureTime(); - - var aimedArrivalTimeSeconds = ServiceDateUtils.secondsSinceStartOfService( - departureDate, - aimedArrivalTime, - timeZone - ); - - var aimedDepartureTime = call.getAimedDepartureTime() != null - ? call.getAimedDepartureTime() - : call.getAimedArrivalTime(); - - var aimedDepartureTimeSeconds = ServiceDateUtils.secondsSinceStartOfService( - departureDate, - aimedDepartureTime, - timeZone - ); - - // Use departure time for first stop, and arrival time for last stop, to avoid negative dwell times - stopTime.setArrivalTime(isFirstStop ? aimedDepartureTimeSeconds : aimedArrivalTimeSeconds); - stopTime.setDepartureTime(isLastStop ? aimedArrivalTimeSeconds : aimedDepartureTimeSeconds); - - // Update destination display - var destinationDisplay = getFirstNameFromList(call.getDestinationDisplaies()); - if (!destinationDisplay.isEmpty()) { - stopTime.setStopHeadsign(new NonLocalizedString(destinationDisplay)); - } else if (trip.getHeadsign() != null) { - stopTime.setStopHeadsign(trip.getHeadsign()); - } else { - // Fallback to empty string - stopTime.setStopHeadsign(new NonLocalizedString("")); - } - - // Update pickup / dropoff - PickDropMapper.mapPickUpType(call, stopTime.getPickupType()).ifPresent(stopTime::setPickupType); - PickDropMapper - .mapDropOffType(call, stopTime.getDropOffType()) - .ifPresent(stopTime::setDropOffType); - - return stopTime; - } - private static String getFirstNameFromList(List names) { if (names == null) { return ""; diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/ExtraCallTripBuilder.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/ExtraCallTripBuilder.java new file mode 100644 index 00000000000..7855db7e5f6 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/ExtraCallTripBuilder.java @@ -0,0 +1,169 @@ +package org.opentripplanner.updater.trip.siri; + +import static java.lang.Boolean.TRUE; +import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.INVALID_STOP_SEQUENCE; +import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_START_DATE; +import static org.opentripplanner.updater.spi.UpdateError.UpdateErrorType.NO_VALID_STOPS; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import org.opentripplanner.model.StopTime; +import org.opentripplanner.transit.model.framework.DataValidationException; +import org.opentripplanner.transit.model.framework.FeedScopedId; +import org.opentripplanner.transit.model.framework.Result; +import org.opentripplanner.transit.model.network.StopPattern; +import org.opentripplanner.transit.model.network.TripPattern; +import org.opentripplanner.transit.model.timetable.RealTimeState; +import org.opentripplanner.transit.model.timetable.RealTimeTripTimes; +import org.opentripplanner.transit.model.timetable.Trip; +import org.opentripplanner.transit.model.timetable.TripTimesFactory; +import org.opentripplanner.transit.service.TransitEditorService; +import org.opentripplanner.updater.spi.DataValidationExceptionMapper; +import org.opentripplanner.updater.spi.UpdateError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.org.siri.siri20.EstimatedVehicleJourney; +import uk.org.siri.siri20.OccupancyEnumeration; + +class ExtraCallTripBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(ExtraCallTripBuilder.class); + private final TransitEditorService transitService; + private final ZoneId timeZone; + private final Function getTripPatternId; + private final Trip trip; + private final String dataSource; + private final LocalDate serviceDate; + private final List calls; + private final boolean isJourneyPredictionInaccurate; + private final OccupancyEnumeration occupancy; + private final boolean cancellation; + private final StopTimesMapper stopTimesMapper; + + ExtraCallTripBuilder( + EstimatedVehicleJourney estimatedVehicleJourney, + TransitEditorService transitService, + EntityResolver entityResolver, + Function getTripPatternId, + Trip trip + ) { + this.trip = Objects.requireNonNull(trip); + + // DataSource of added trip + dataSource = estimatedVehicleJourney.getDataSource(); + + serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney); + + isJourneyPredictionInaccurate = TRUE.equals(estimatedVehicleJourney.isPredictionInaccurate()); + occupancy = estimatedVehicleJourney.getOccupancy(); + cancellation = TRUE.equals(estimatedVehicleJourney.isCancellation()); + + calls = CallWrapper.of(estimatedVehicleJourney); + + this.transitService = transitService; + this.getTripPatternId = getTripPatternId; + timeZone = transitService.getTimeZone(); + + stopTimesMapper = new StopTimesMapper(entityResolver, timeZone); + } + + Result build() { + if (calls.size() <= transitService.findPattern(trip).numberOfStops()) { + // An extra call trip update is expected to have at least one more stop than the scheduled trip + return UpdateError.result(trip.getId(), INVALID_STOP_SEQUENCE, dataSource); + } + + if (serviceDate == null) { + return UpdateError.result(trip.getId(), NO_START_DATE, dataSource); + } + + FeedScopedId calServiceId = transitService.getOrCreateServiceIdForDate(serviceDate); + if (calServiceId == null) { + return UpdateError.result(trip.getId(), NO_START_DATE, dataSource); + } + + ZonedDateTime departureDate = serviceDate.atStartOfDay(timeZone); + + // Create the "scheduled version" of the trip + var aimedStopTimes = new ArrayList(); + for (int stopSequence = 0; stopSequence < calls.size(); stopSequence++) { + StopTime stopTime = stopTimesMapper.createStopTime( + trip, + departureDate, + stopSequence, + calls.get(stopSequence), + stopSequence == 0, + stopSequence == (calls.size() - 1) + ); + + // Drop this update if the call refers to an unknown stop (not present in the site repository). + if (stopTime == null) { + return UpdateError.result(trip.getId(), NO_VALID_STOPS, dataSource); + } + + aimedStopTimes.add(stopTime); + } + + // TODO: We always create a new TripPattern to be able to modify its scheduled timetable + StopPattern stopPattern = new StopPattern(aimedStopTimes); + + RealTimeTripTimes tripTimes = TripTimesFactory.tripTimes( + trip, + aimedStopTimes, + transitService.getDeduplicator() + ); + // validate the scheduled trip times + // they are in general superseded by real-time trip times + // but in case of trip cancellation, OTP will fall back to scheduled trip times + // therefore they must be valid + tripTimes.validateNonIncreasingTimes(); + tripTimes.setServiceCode(transitService.getServiceCode(trip.getServiceId())); + + TripPattern pattern = TripPattern + .of(getTripPatternId.apply(trip)) + .withRoute(trip.getRoute()) + .withMode(trip.getMode()) + .withNetexSubmode(trip.getNetexSubMode()) + .withStopPattern(stopPattern) + .withScheduledTimeTableBuilder(builder -> builder.addTripTimes(tripTimes)) + .withCreatedByRealtimeUpdater(true) + .build(); + + RealTimeTripTimes updatedTripTimes = tripTimes.copyScheduledTimes(); + + // Loop through calls again and apply updates + for (int stopSequence = 0; stopSequence < calls.size(); stopSequence++) { + TimetableHelper.applyUpdates( + departureDate, + updatedTripTimes, + stopSequence, + stopSequence == (calls.size() - 1), + isJourneyPredictionInaccurate, + calls.get(stopSequence), + occupancy + ); + } + + if (cancellation || stopPattern.isAllStopsNonRoutable()) { + updatedTripTimes.cancelTrip(); + } else { + updatedTripTimes.setRealTimeState(RealTimeState.MODIFIED); + } + + /* Validate */ + try { + updatedTripTimes.validateNonIncreasingTimes(); + } catch (DataValidationException e) { + return DataValidationExceptionMapper.toResult(e, dataSource); + } + + return Result.success( + new TripUpdate(stopPattern, updatedTripTimes, serviceDate, null, pattern, false, dataSource) + ); + } +} diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriRealTimeTripUpdateAdapter.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriRealTimeTripUpdateAdapter.java index 8ee723567c0..b754559e216 100644 --- a/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriRealTimeTripUpdateAdapter.java +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriRealTimeTripUpdateAdapter.java @@ -127,22 +127,21 @@ private Result apply( return UpdateError.result(null, EMPTY_STOP_POINT_REF, journey.getDataSource()); } } - boolean shouldAddNewTrip = false; + SiriUpdateType siriUpdateType = null; try { - shouldAddNewTrip = shouldAddNewTrip(journey, entityResolver); - Result result; - if (shouldAddNewTrip) { - result = - new AddedTripBuilder( + siriUpdateType = updateType(journey, entityResolver); + Result result = + switch (siriUpdateType) { + case REPLACEMENT_DEPARTURE -> new AddedTripBuilder( journey, transitService, entityResolver, tripPatternIdGenerator::generateUniqueTripPatternId ) .build(); - } else { - result = handleModifiedTrip(fuzzyTripMatcher, entityResolver, journey); - } + case EXTRA_CALL -> handleExtraCall(fuzzyTripMatcher, entityResolver, journey); + case TRIP_UPDATE -> handleModifiedTrip(fuzzyTripMatcher, entityResolver, journey); + }; if (result.isFailure()) { return result.toFailureResult(); @@ -153,30 +152,49 @@ private Result apply( } catch (DataValidationException e) { return DataValidationExceptionMapper.toResult(e, journey.getDataSource()); } catch (Exception e) { - LOG.warn( - "{} EstimatedJourney {} failed.", - shouldAddNewTrip ? "Adding" : "Updating", - DebugString.of(journey), - e - ); + LOG.warn("{} EstimatedJourney {} failed.", siriUpdateType, DebugString.of(journey), e); return Result.failure(UpdateError.noTripId(UNKNOWN)); } } - /** - * Check if VehicleJourney is a replacement departure according to SIRI-ET requirements. - */ - private boolean shouldAddNewTrip( + private SiriUpdateType updateType( EstimatedVehicleJourney vehicleJourney, EntityResolver entityResolver ) { - // Replacement departure only if ExtraJourney is true - if (!(TRUE.equals(vehicleJourney.isExtraJourney()))) { - return false; + // Extra call if at least one of the call is an extra call + if ( + ( + vehicleJourney.getRecordedCalls() != null && + TRUE.equals( + vehicleJourney + .getRecordedCalls() + .getRecordedCalls() + .stream() + .anyMatch(recordedCall -> Boolean.TRUE.equals(recordedCall.isExtraCall())) + ) || + ( + vehicleJourney.getEstimatedCalls() != null && + vehicleJourney + .getEstimatedCalls() + .getEstimatedCalls() + .stream() + .anyMatch(estimatedCall -> Boolean.TRUE.equals(estimatedCall.isExtraCall())) + ) + ) + ) { + return SiriUpdateType.EXTRA_CALL; + } + + // Replacement departure if the trip is marked as extra journey and it has not been added before + if ( + TRUE.equals(vehicleJourney.isExtraJourney()) && + entityResolver.resolveTrip(vehicleJourney) == null + ) { + return SiriUpdateType.REPLACEMENT_DEPARTURE; } - // And if the trip has not been added before - return entityResolver.resolveTrip(vehicleJourney) == null; + // otherwise this is a trip update + return SiriUpdateType.TRIP_UPDATE; } /** @@ -271,6 +289,88 @@ private Result handleModifiedTrip( return updateResult; } + private Result handleExtraCall( + @Nullable SiriFuzzyTripMatcher fuzzyTripMatcher, + EntityResolver entityResolver, + EstimatedVehicleJourney estimatedVehicleJourney + ) { + Trip trip = entityResolver.resolveTrip(estimatedVehicleJourney); + String dataSource = estimatedVehicleJourney.getDataSource(); + + // Check if EstimatedVehicleJourney is reported as NOT monitored, ignore the notMonitored-flag + // if the journey is NOT monitored because it has been cancelled + if ( + !TRUE.equals(estimatedVehicleJourney.isMonitored()) && + !TRUE.equals(estimatedVehicleJourney.isCancellation()) + ) { + return UpdateError.result(trip != null ? trip.getId() : null, NOT_MONITORED, dataSource); + } + + LocalDate serviceDate = entityResolver.resolveServiceDate(estimatedVehicleJourney); + + if (serviceDate == null) { + return UpdateError.result(trip != null ? trip.getId() : null, NO_START_DATE, dataSource); + } + + TripPattern pattern; + + if (trip != null) { + // Found exact match + pattern = transitEditorService.findPattern(trip); + } else if (fuzzyTripMatcher != null) { + // No exact match found - search for trips based on arrival-times/stop-patterns + var result = fuzzyTripMatcher.match( + estimatedVehicleJourney, + entityResolver, + this::getCurrentTimetable, + snapshotManager::getNewTripPatternForModifiedTrip + ); + + if (result.isFailure()) { + LOG.debug( + "No trips found for EstimatedVehicleJourney. {}", + DebugString.of(estimatedVehicleJourney) + ); + return UpdateError.result(null, result.failureValue(), dataSource); + } + + var tripAndPattern = result.successValue(); + trip = tripAndPattern.trip(); + pattern = tripAndPattern.tripPattern(); + } else { + return UpdateError.result(null, TRIP_NOT_FOUND, dataSource); + } + + Timetable currentTimetable = getCurrentTimetable(pattern, serviceDate); + TripTimes existingTripTimes = currentTimetable.getTripTimes(trip); + if (existingTripTimes == null) { + LOG.debug("tripId {} not found in pattern.", trip.getId()); + return UpdateError.result(trip.getId(), TRIP_NOT_FOUND_IN_PATTERN, dataSource); + } + var updateResult = new ExtraCallTripBuilder( + estimatedVehicleJourney, + transitEditorService, + entityResolver, + tripPatternIdGenerator::generateUniqueTripPatternId, + trip + ) + .build(); + if (updateResult.isFailure()) { + return updateResult.toFailureResult(); + } + + if (!updateResult.successValue().stopPattern().equals(pattern.getStopPattern())) { + // Replace scheduled trip pattern, if pattern has changed + markScheduledTripAsDeleted(trip, serviceDate); + } + + // Also check whether trip id has been used for previously ADDED/MODIFIED trip message and + // remove the previously created trip + this.snapshotManager.revertTripToScheduledTripPattern(trip.getId(), serviceDate); + + return updateResult; + } + /** * Add a (new) trip to the timetableRepository and the buffer */ diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriUpdateType.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriUpdateType.java new file mode 100644 index 00000000000..923d4a74021 --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/SiriUpdateType.java @@ -0,0 +1,29 @@ +package org.opentripplanner.updater.trip.siri; + +/** + * Types of SIRI update messages. + */ +public enum SiriUpdateType { + /** + * Update of an existing trip. + * This can be either a trip defined in planned data or a replacement departure + * that was previously added by a real-time message. + * The update can consist in updated passing times and/or cancellation of some stops. + * A stop can be substituted by another if they belong to the same station. + * The whole trip can also be marked as cancelled. + */ + TRIP_UPDATE, + + /** + * Addition of a new trip, not currently present in the system. + * The new trip has a new unique id. + * The trip can replace one or more existing trips, another SIRI message should handle the + * cancellation of the replaced trips. + */ + REPLACEMENT_DEPARTURE, + + /** + * Addition of one or more stops in an existing trip. + */ + EXTRA_CALL, +} diff --git a/application/src/main/java/org/opentripplanner/updater/trip/siri/StopTimesMapper.java b/application/src/main/java/org/opentripplanner/updater/trip/siri/StopTimesMapper.java new file mode 100644 index 00000000000..59d9438602e --- /dev/null +++ b/application/src/main/java/org/opentripplanner/updater/trip/siri/StopTimesMapper.java @@ -0,0 +1,97 @@ +package org.opentripplanner.updater.trip.siri; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import org.opentripplanner.framework.i18n.NonLocalizedString; +import org.opentripplanner.model.StopTime; +import org.opentripplanner.transit.model.site.RegularStop; +import org.opentripplanner.transit.model.timetable.Trip; +import org.opentripplanner.updater.trip.siri.mapping.PickDropMapper; +import org.opentripplanner.utils.time.ServiceDateUtils; +import uk.org.siri.siri20.NaturalLanguageStringStructure; + +class StopTimesMapper { + + private final EntityResolver entityResolver; + private final ZoneId zoneId; + + public StopTimesMapper(EntityResolver entityResolver, ZoneId zoneId) { + this.entityResolver = entityResolver; + this.zoneId = zoneId; + } + + /** + * Map the call to a StopTime or return null if the stop cannot be found in the site repository. + */ + StopTime createStopTime( + Trip trip, + ZonedDateTime departureDate, + int stopSequence, + CallWrapper call, + boolean isFirstStop, + boolean isLastStop + ) { + RegularStop stop = entityResolver.resolveQuay(call.getStopPointRef()); + if (stop == null) { + return null; + } + + StopTime stopTime = new StopTime(); + stopTime.setStopSequence(stopSequence); + stopTime.setTrip(trip); + stopTime.setStop(stop); + + // Fallback to other time, if one doesn't exist + var aimedArrivalTime = call.getAimedArrivalTime() != null + ? call.getAimedArrivalTime() + : call.getAimedDepartureTime(); + + var aimedArrivalTimeSeconds = ServiceDateUtils.secondsSinceStartOfService( + departureDate, + aimedArrivalTime, + zoneId + ); + + var aimedDepartureTime = call.getAimedDepartureTime() != null + ? call.getAimedDepartureTime() + : call.getAimedArrivalTime(); + + var aimedDepartureTimeSeconds = ServiceDateUtils.secondsSinceStartOfService( + departureDate, + aimedDepartureTime, + zoneId + ); + + // Use departure time for first stop, and arrival time for last stop, to avoid negative dwell times + stopTime.setArrivalTime(isFirstStop ? aimedDepartureTimeSeconds : aimedArrivalTimeSeconds); + stopTime.setDepartureTime(isLastStop ? aimedArrivalTimeSeconds : aimedDepartureTimeSeconds); + + // Update destination display + var destinationDisplay = getFirstNameFromList(call.getDestinationDisplaies()); + if (!destinationDisplay.isEmpty()) { + stopTime.setStopHeadsign(new NonLocalizedString(destinationDisplay)); + } else if (trip.getHeadsign() != null) { + stopTime.setStopHeadsign(trip.getHeadsign()); + } else { + // Fallback to empty string + stopTime.setStopHeadsign(new NonLocalizedString("")); + } + + // Update pickup / dropoff + PickDropMapper.mapPickUpType(call, stopTime.getPickupType()).ifPresent(stopTime::setPickupType); + PickDropMapper + .mapDropOffType(call, stopTime.getDropOffType()) + .ifPresent(stopTime::setDropOffType); + + return stopTime; + } + + //TODO move to helper + private static String getFirstNameFromList(List names) { + if (names == null) { + return ""; + } + return names.stream().findFirst().map(NaturalLanguageStringStructure::getValue).orElse(""); + } +} diff --git a/application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/unsupported/UnsupportedTest.java b/application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/extracall/ExtraCallTest.java similarity index 52% rename from application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/unsupported/UnsupportedTest.java rename to application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/extracall/ExtraCallTest.java index be3aaae798f..c27b7497a58 100644 --- a/application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/unsupported/UnsupportedTest.java +++ b/application/src/test/java/org/opentripplanner/updater/trip/siri/moduletests/extracall/ExtraCallTest.java @@ -1,8 +1,9 @@ -package org.opentripplanner.updater.trip.siri.moduletests.unsupported; +package org.opentripplanner.updater.trip.siri.moduletests.extracall; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.opentripplanner.updater.spi.UpdateResultAssertions.assertFailure; +import java.util.List; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.opentripplanner.updater.spi.UpdateError; @@ -10,11 +11,9 @@ import org.opentripplanner.updater.trip.RealtimeTestEnvironment; import org.opentripplanner.updater.trip.TripInput; import org.opentripplanner.updater.trip.siri.SiriEtBuilder; +import uk.org.siri.siri20.EstimatedTimetableDeliveryStructure; -/** - * Contains tests for features that are not currently supported. - */ -class UnsupportedTest implements RealtimeTestConstants { +class ExtraCallTest implements RealtimeTestConstants { private static final TripInput TRIP_1_INPUT = TripInput .of(TRIP_1_ID) @@ -23,29 +22,55 @@ class UnsupportedTest implements RealtimeTestConstants { .build(); @Test - @Disabled("Not supported yet") - void testAddStop() { + void testExtraCall() { var env = RealtimeTestEnvironment.of().addTrip(TRIP_1_INPUT).build(); - var updates = new SiriEtBuilder(env.getDateTimeHelper()) + var updates = updateWithExtraCall(env); + + var result = env.applyEstimatedTimetable(updates); + + assertEquals(1, result.successful()); + assertEquals( + "MODIFIED | A1 [R] 0:00:15 0:00:15 | D1 0:00:20 0:00:25 | B1 0:00:33 0:00:33", + env.getRealtimeTimetable(TRIP_1_ID) + ); + } + + @Test + void testExtraCallMultipleTimes() { + var env = RealtimeTestEnvironment.of().addTrip(TRIP_1_INPUT).build(); + + var updates = updateWithExtraCall(env); + env.applyEstimatedTimetable(updates); + var result = env.applyEstimatedTimetable(updates); + + assertEquals(1, result.successful()); + assertEquals( + "MODIFIED | A1 [R] 0:00:15 0:00:15 | D1 0:00:20 0:00:25 | B1 0:00:33 0:00:33", + env.getRealtimeTimetable(TRIP_1_ID) + ); + } + + @Test + void testExtraCallAndCancellation() { + var env = RealtimeTestEnvironment.of().addTrip(TRIP_1_INPUT).build(); + + var updates = updateWithExtraCall(env); + env.applyEstimatedTimetable(updates); + var result = env.applyEstimatedTimetable(updates); + + var cancellation = new SiriEtBuilder(env.getDateTimeHelper()) .withDatedVehicleJourneyRef(TRIP_1_ID) - .withRecordedCalls(builder -> builder.call(STOP_A1).departAimedActual("00:00:11", "00:00:15")) - .withEstimatedCalls(builder -> - builder - .call(STOP_D1) - .withIsExtraCall(true) - .arriveAimedExpected("00:00:19", "00:00:20") - .departAimedExpected("00:00:24", "00:00:25") - .call(STOP_B1) - .arriveAimedExpected("00:00:20", "00:00:33") - ) + .withCancellation(true) .buildEstimatedTimetableDeliveries(); - var result = env.applyEstimatedTimetable(updates); + var cancellationResult = env.applyEstimatedTimetable(cancellation); + + assertEquals(1, cancellationResult.successful()); assertEquals(1, result.successful()); assertEquals( - "MODIFIED | A1 0:00:15 0:00:15 | D1 [C] 0:00:20 0:00:25 | B1 0:00:33 0:00:33", + "CANCELED | A1 0:00:10 0:00:11 | B1 0:00:20 0:00:21", env.getRealtimeTimetable(TRIP_1_ID) ); } @@ -66,7 +91,7 @@ void testExtraUnknownStop() { .arriveAimedExpected("00:00:19", "00:00:20") .departAimedExpected("00:00:24", "00:00:25") .call(STOP_B1) - .arriveAimedExpected("00:00:20", "00:00:33") + .arriveAimedExpected("00:00:30", "00:00:33") ) .buildEstimatedTimetableDeliveries(); @@ -74,4 +99,23 @@ void testExtraUnknownStop() { assertFailure(UpdateError.UpdateErrorType.INVALID_STOP_SEQUENCE, result); } + + private static List updateWithExtraCall( + RealtimeTestEnvironment env + ) { + return new SiriEtBuilder(env.getDateTimeHelper()) + .withDatedVehicleJourneyRef(TRIP_1_ID) + .withLineRef(ROUTE_1_ID) + .withRecordedCalls(builder -> builder.call(STOP_A1).departAimedActual("00:00:11", "00:00:15")) + .withEstimatedCalls(builder -> + builder + .call(STOP_D1) + .withIsExtraCall(true) + .arriveAimedExpected("00:00:18", "00:00:20") + .departAimedExpected("00:00:19", "00:00:25") + .call(STOP_B1) + .arriveAimedExpected("00:00:20", "00:00:33") + ) + .buildEstimatedTimetableDeliveries(); + } }