diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java new file mode 100644 index 0000000000..38176f16fa --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidator.java @@ -0,0 +1,128 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.INFO; + +import java.util.List; +import java.util.Optional; +import javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice.FileRefs; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStop; +import org.mobilitydata.gtfsvalidator.table.GtfsStopSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTrip; +import org.mobilitydata.gtfsvalidator.table.GtfsTripSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer; + +/** + * Validates that the trip headsign does not match the name of any intermediate stop (i.e., any stop + * that is not the last stop of the trip). + * + *
Generated notice: {@link TripHeadsignMatchesIntermediateStopNotice}.
+ */
+@GtfsValidator
+public class TripHeadsignValidator extends FileValidator {
+ private final GtfsTripTableContainer tripTable;
+ private final GtfsStopTimeTableContainer stopTimeTable;
+ private final GtfsStopTableContainer stopTable;
+
+ @Inject
+ TripHeadsignValidator(
+ GtfsTripTableContainer tripTable,
+ GtfsStopTimeTableContainer stopTimeTable,
+ GtfsStopTableContainer stopTable) {
+ this.tripTable = tripTable;
+ this.stopTimeTable = stopTimeTable;
+ this.stopTable = stopTable;
+ }
+
+ @Override
+ public void validate(NoticeContainer noticeContainer) {
+ for (GtfsTrip trip : tripTable.getEntities()) {
+ if (!trip.hasTripHeadsign()) {
+ continue;
+ }
+ String headsign = trip.tripHeadsign();
+ String tripId = trip.tripId();
+
+ List The `trip_headsign` matches the `stop_name` of a stop that is not the last stop of the trip.
+ * This may confuse passengers boarding after that stop, since the headsign suggests the vehicle
+ * is heading to a stop it has already passed.
+ */
+ @GtfsValidationNotice(
+ severity = INFO,
+ files = @FileRefs({GtfsTripSchema.class, GtfsStopTimeSchema.class, GtfsStopSchema.class}))
+ static class TripHeadsignMatchesIntermediateStopNotice extends ValidationNotice {
+
+ /** The row number of the faulty record in `trips.txt`. */
+ private final long csvRowNumber;
+
+ /** The id of the trip with the problematic headsign. */
+ private final String tripId;
+
+ /** The headsign value that matches an intermediate stop name. */
+ private final String tripHeadsign;
+
+ /** The id of the intermediate stop whose name matches the headsign. */
+ private final String stopId1;
+
+ /** The stop_sequence value of the intermediate stop that matches the headsign. */
+ private final int stopSequence;
+
+ /** The id of the actual last stop of the trip. */
+ private final String stopId2;
+
+ TripHeadsignMatchesIntermediateStopNotice(
+ long csvRowNumber,
+ String tripId,
+ String tripHeadsign,
+ String stopId1,
+ int stopSequence,
+ String stopId2) {
+ this.csvRowNumber = csvRowNumber;
+ this.tripId = tripId;
+ this.tripHeadsign = tripHeadsign;
+ this.stopId1 = stopId1;
+ this.stopSequence = stopSequence;
+ this.stopId2 = stopId2;
+ }
+ }
+}
diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java
index 1bbcb90a17..7a6840a1f0 100644
--- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java
+++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java
@@ -231,6 +231,7 @@ public void testNoticeClassFieldNames() {
"transferCount",
"tripCsvRowNumber",
"tripFieldName",
+ "tripHeadsign",
"tripId",
"tripIdA",
"tripIdB",
diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java
new file mode 100644
index 0000000000..0e02a03e2f
--- /dev/null
+++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TripHeadsignValidatorTest.java
@@ -0,0 +1,167 @@
+package org.mobilitydata.gtfsvalidator.validator;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Test;
+import org.mobilitydata.gtfsvalidator.notice.NoticeContainer;
+import org.mobilitydata.gtfsvalidator.notice.ValidationNotice;
+import org.mobilitydata.gtfsvalidator.table.GtfsStop;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTime;
+import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer;
+import org.mobilitydata.gtfsvalidator.table.GtfsTrip;
+import org.mobilitydata.gtfsvalidator.table.GtfsTripTableContainer;
+import org.mobilitydata.gtfsvalidator.validator.TripHeadsignValidator.TripHeadsignMatchesIntermediateStopNotice;
+
+public class TripHeadsignValidatorTest {
+
+ @Test
+ public void headsignMatchingLastStopShouldNotGenerateNotice() {
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Central Station")),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_a", 1),
+ createStopTime(0, "t0", "stop_b", 2),
+ createStopTime(0, "t0", "stop_central", 3)),
+ ImmutableList.of(
+ createStop("stop_a", "Airport"),
+ createStop("stop_b", "City Hall"),
+ createStop("stop_central", "Central Station"))))
+ .isEmpty();
+ }
+
+ @Test
+ public void headsignMatchingIntermediateStopShouldGenerateNotice() {
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "City Hall")),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_a", 1),
+ createStopTime(0, "t0", "stop_b", 2),
+ createStopTime(0, "t0", "stop_central", 3)),
+ ImmutableList.of(
+ createStop("stop_a", "Airport"),
+ createStop("stop_b", "City Hall"),
+ createStop("stop_central", "Central Station"))))
+ .containsExactly(
+ new TripHeadsignMatchesIntermediateStopNotice(
+ 1, "t0", "City Hall", "stop_b", 2, "stop_central"));
+ }
+
+ @Test
+ public void tripWithNoHeadsignShouldNotGenerateNotice() {
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", null)),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_a", 1), createStopTime(0, "t0", "stop_b", 2)),
+ ImmutableList.of(
+ createStop("stop_a", "Airport"), createStop("stop_b", "City Hall"))))
+ .isEmpty();
+ }
+
+ @Test
+ public void tripWithSingleStopShouldNotGenerateNotice() {
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Airport")),
+ ImmutableList.of(createStopTime(0, "t0", "stop_a", 1)),
+ ImmutableList.of(createStop("stop_a", "Airport"))))
+ .isEmpty();
+ }
+
+ @Test
+ public void multipleIntermediateStopsMatchingHeadsignShouldGenerateOneNoticeEach() {
+ // Both stop_a and stop_b share the same name as the headsign. The validator checks every
+ // intermediate stop independently, so a separate notice should fire for each match.
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "City Hall")),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_a", 1),
+ createStopTime(0, "t0", "stop_b", 2),
+ createStopTime(0, "t0", "stop_c", 3)),
+ ImmutableList.of(
+ createStop("stop_a", "City Hall"),
+ createStop("stop_b", "City Hall"),
+ createStop("stop_c", "Central Station"))))
+ .containsExactly(
+ new TripHeadsignMatchesIntermediateStopNotice(
+ 1, "t0", "City Hall", "stop_a", 1, "stop_c"),
+ new TripHeadsignMatchesIntermediateStopNotice(
+ 1, "t0", "City Hall", "stop_b", 2, "stop_c"));
+ }
+
+ @Test
+ public void intermediateStopAbsentFromStopsTableShouldNotGenerateNotice() {
+ // When a stop_id referenced in stop_times.txt does not exist in stops.txt the validator's
+ // stopTable.byStopId() returns empty. The broken foreign key is reported by a separate rule;
+ // this validator should simply skip the missing stop rather than crash or emit a false notice.
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Ghost Stop")),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_ghost", 1), createStopTime(0, "t0", "stop_b", 2)),
+ ImmutableList.of(
+ // stop_ghost is intentionally absent from the stops table
+ createStop("stop_b", "Central Station"))))
+ .isEmpty();
+ }
+
+ @Test
+ public void headsignMatchingFirstStopOfMultiStopTripShouldGenerateNotice() {
+ assertThat(
+ generateNotices(
+ ImmutableList.of(createTrip(1, "r1", "s1", "t0", "Airport")),
+ ImmutableList.of(
+ createStopTime(0, "t0", "stop_a", 1),
+ createStopTime(0, "t0", "stop_b", 2),
+ createStopTime(0, "t0", "stop_c", 3)),
+ ImmutableList.of(
+ createStop("stop_a", "Airport"),
+ createStop("stop_b", "City Hall"),
+ createStop("stop_c", "Central Station"))))
+ .containsExactly(
+ new TripHeadsignMatchesIntermediateStopNotice(
+ 1, "t0", "Airport", "stop_a", 1, "stop_c"));
+ }
+
+ private static List