diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java new file mode 100644 index 0000000000..363f3c385e --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidator.java @@ -0,0 +1,109 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import java.util.List; +import java.util.Optional; +import javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.MissingRequiredFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsContinuousPickupDropOff; +import org.mobilitydata.gtfsvalidator.table.GtfsRoute; +import org.mobilitydata.gtfsvalidator.table.GtfsRouteTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTrip; +import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer; + +/** + * Validates that {@code trips.shape_id} is defined when the trip has continuous pickup or drop-off + * behavior. + * + *

The spec makes {@code trips.shape_id} conditionally required: it is required if the trip has a + * continuous pickup or drop-off behavior defined either in {@code routes.txt} or in {@code + * stop_times.txt}, and optional otherwise. + * + *

A value of {@code 1}, like an empty value, means no continuous stopping behavior, so only + * {@code 0}, {@code 2} and {@code 3} make {@code shape_id} required. Values in {@code + * stop_times.txt} override those in {@code routes.txt}. + * + *

Generated notice: {@link MissingRequiredFieldNotice}. + */ +@GtfsValidator +public class TripShapeIdConditionalValidator extends FileValidator { + private final GtfsRouteTableContainer routeTable; + private final GtfsTripTableContainer tripTable; + private final GtfsStopTimeTableContainer stopTimeTable; + + @Inject + TripShapeIdConditionalValidator( + GtfsRouteTableContainer routeTable, + GtfsTripTableContainer tripTable, + GtfsStopTimeTableContainer stopTimeTable) { + this.routeTable = routeTable; + this.tripTable = tripTable; + this.stopTimeTable = stopTimeTable; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + for (GtfsTrip trip : tripTable.getEntities()) { + if (trip.hasShapeId()) { + continue; + } + if (hasContinuousBehavior(trip)) { + noticeContainer.addValidationNotice( + new MissingRequiredFieldNotice( + GtfsTrip.FILENAME, trip.csvRowNumber(), GtfsTrip.SHAPE_ID_FIELD_NAME)); + } + } + } + + /** + * Returns true if any stop time of this trip has continuous pickup or drop-off behavior, falling + * back to the values on the route where the stop time does not set them. + */ + private boolean hasContinuousBehavior(GtfsTrip trip) { + Optional route = routeTable.byRouteId(trip.routeId()); + GtfsContinuousPickupDropOff routePickup = + route.map(GtfsRoute::continuousPickup).orElse(GtfsContinuousPickupDropOff.NOT_AVAILABLE); + GtfsContinuousPickupDropOff routeDropOff = + route.map(GtfsRoute::continuousDropOff).orElse(GtfsContinuousPickupDropOff.NOT_AVAILABLE); + + List stopTimes = stopTimeTable.byTripId(trip.tripId()); + if (stopTimes.isEmpty()) { + return isContinuous(routePickup) || isContinuous(routeDropOff); + } + for (GtfsStopTime stopTime : stopTimes) { + GtfsContinuousPickupDropOff pickup = + stopTime.hasContinuousPickup() ? stopTime.continuousPickup() : routePickup; + GtfsContinuousPickupDropOff dropOff = + stopTime.hasContinuousDropOff() ? stopTime.continuousDropOff() : routeDropOff; + if (isContinuous(pickup) || isContinuous(dropOff)) { + return true; + } + } + return false; + } + + private static boolean isContinuous(GtfsContinuousPickupDropOff value) { + return value == GtfsContinuousPickupDropOff.ALLOWED + || value == GtfsContinuousPickupDropOff.MUST_PHONE + || value == GtfsContinuousPickupDropOff.ON_REQUEST_TO_DRIVER; + } + + @Override + public boolean shouldCallValidate() { + if (tripTable == null) { + return false; + } + boolean routeDefinesContinuous = + routeTable != null + && (routeTable.hasColumn(GtfsRoute.CONTINUOUS_PICKUP_FIELD_NAME) + || routeTable.hasColumn(GtfsRoute.CONTINUOUS_DROP_OFF_FIELD_NAME)); + boolean stopTimeDefinesContinuous = + stopTimeTable != null + && (stopTimeTable.hasColumn(GtfsStopTime.CONTINUOUS_PICKUP_FIELD_NAME) + || stopTimeTable.hasColumn(GtfsStopTime.CONTINUOUS_DROP_OFF_FIELD_NAME)); + return routeDefinesContinuous || stopTimeDefinesContinuous; + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java new file mode 100644 index 0000000000..09e897c5f9 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripShapeIdConditionalValidatorTest.java @@ -0,0 +1,143 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.MissingRequiredFieldNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsRoute; +import org.mobilitydata.gtfsvalidator.table.GtfsRouteTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTrip; +import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer; + +@RunWith(JUnit4.class) +public class TripShapeIdConditionalValidatorTest { + + private static List generateNotices( + List routes, List trips, List stopTimes) { + NoticeContainer noticeContainer = new NoticeContainer(); + new TripShapeIdConditionalValidator( + GtfsRouteTableContainer.forEntities(routes, noticeContainer), + GtfsTripTableContainer.forEntities(trips, noticeContainer), + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer)) + .validate(noticeContainer); + return noticeContainer.getValidationNotices(); + } + + private static GtfsRoute.Builder route() { + return new GtfsRoute.Builder().setCsvRowNumber(2).setRouteId("route1"); + } + + private static GtfsTrip.Builder trip() { + return new GtfsTrip.Builder().setCsvRowNumber(3).setTripId("trip1").setRouteId("route1"); + } + + private static GtfsStopTime.Builder stopTime(int csvRowNumber) { + return new GtfsStopTime.Builder() + .setCsvRowNumber(csvRowNumber) + .setTripId("trip1") + .setStopSequence(csvRowNumber); + } + + private static MissingRequiredFieldNotice expectedNotice() { + return new MissingRequiredFieldNotice("trips.txt", 3, "shape_id"); + } + + @Test + public void continuousPickupOnRouteWithoutShapeIdShouldGenerateNotice() { + List notices = + generateNotices( + List.of(route().setContinuousPickup(0).build()), + List.of(trip().build()), + List.of(stopTime(4).build())); + assertThat(notices).containsExactly(expectedNotice()); + } + + @Test + public void continuousDropOffOnRouteWithoutShapeIdShouldGenerateNotice() { + List notices = + generateNotices( + List.of(route().setContinuousDropOff(2).build()), + List.of(trip().build()), + List.of(stopTime(4).build())); + assertThat(notices).containsExactly(expectedNotice()); + } + + @Test + public void continuousPickupOnStopTimeWithoutShapeIdShouldGenerateNotice() { + List notices = + generateNotices( + List.of(route().build()), + List.of(trip().build()), + List.of(stopTime(4).build(), stopTime(5).setContinuousPickup(3).build())); + assertThat(notices).containsExactly(expectedNotice()); + } + + @Test + public void continuousBehaviorWithShapeIdShouldNotGenerateNotice() { + List notices = + generateNotices( + List.of(route().setContinuousPickup(0).build()), + List.of(trip().setShapeId("shape1").build()), + List.of(stopTime(4).build())); + assertThat(notices).isEmpty(); + } + + @Test + public void notAvailableContinuousBehaviorShouldNotGenerateNotice() { + List notices = + generateNotices( + List.of(route().setContinuousPickup(1).setContinuousDropOff(1).build()), + List.of(trip().build()), + List.of(stopTime(4).setContinuousPickup(1).setContinuousDropOff(1).build())); + assertThat(notices).isEmpty(); + } + + @Test + public void stopTimeShouldOverrideContinuousBehaviorOnRoute() { + List notices = + generateNotices( + List.of(route().setContinuousPickup(0).build()), + List.of(trip().build()), + List.of(stopTime(4).setContinuousPickup(1).build())); + assertThat(notices).isEmpty(); + } + + @Test + public void oneOverridingStopTimeWithContinuousBehaviorShouldGenerateNotice() { + // Every stop time overrides the route, but one of them overrides it to a continuous value, so + // the trip still needs a shape. + List notices = + generateNotices( + List.of(route().setContinuousPickup(0).build()), + List.of(trip().build()), + List.of( + stopTime(4).setContinuousPickup(1).build(), + stopTime(5).setContinuousPickup(1).build(), + stopTime(6).setContinuousPickup(2).build(), + stopTime(7).setContinuousPickup(1).build())); + assertThat(notices).containsExactly(expectedNotice()); + } + + @Test + public void noContinuousBehaviorShouldNotGenerateNotice() { + List notices = + generateNotices( + List.of(route().build()), List.of(trip().build()), List.of(stopTime(4).build())); + assertThat(notices).isEmpty(); + } + + @Test + public void tripWithoutStopTimesShouldStillUseRouteContinuousBehavior() { + List notices = + generateNotices( + List.of(route().setContinuousPickup(2).build()), List.of(trip().build()), List.of()); + assertThat(notices).containsExactly(expectedNotice()); + } +}