diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidator.java new file mode 100644 index 0000000000..d1595cb434 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidator.java @@ -0,0 +1,75 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.WARNING; + +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.MissingRecommendedFileNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsLocationGroupsTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsShapeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeSchema; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTripSchema; + +/** + * Validates that the feed has either a `shapes.txt` file, or uses zone-based DRT or fixed-stops + * DRT. + * + *

Generated notice: {@link MissingRecommendedFileNotice}. + */ +@GtfsValidator +public class MissingShapesFileValidator extends FileValidator { + private final GtfsShapeTableContainer shapeTable; + private final GtfsStopTimeTableContainer stopTimeTable; + private final GtfsLocationGroupsTableContainer locationGroupsTable; + + @Inject + MissingShapesFileValidator( + GtfsShapeTableContainer shapeTable, + GtfsStopTimeTableContainer stopTimeTable, + GtfsLocationGroupsTableContainer locationGroupsTable) { + this.shapeTable = shapeTable; + this.stopTimeTable = stopTimeTable; + this.locationGroupsTable = locationGroupsTable; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + Boolean missingShapes = shapeTable.isMissingFile(); + Boolean hasLocationId = stopTimeTable.hasColumn("location_id"); + Boolean hasLocationGroupId = stopTimeTable.hasColumn("location_group_id"); + Boolean hasLocationGroupsRecord = + !locationGroupsTable.isMissingFile() && locationGroupsTable.entityCount() > 0; + // Detect DRT usage from the data, not just from column presence. + boolean hasLocationIdInData = false; + boolean hasLocationGroupIdInData = false; + for (GtfsStopTime stopTime : stopTimeTable.getEntities()) { + if (stopTime.hasLocationId()) { + hasLocationIdInData = true; + } + if (stopTime.hasLocationGroupId()) { + hasLocationGroupIdInData = true; + } + if (hasLocationIdInData && hasLocationGroupIdInData) { + break; + } + } + + // Do we not have: a shapes.txt file and not have a location_id (required for Zone-Based DRT), + // and also not have a record in location_groups.txt and not have a trip in stop_times.txt that + // references location_group_id (required for Fixed-Stop DRT)? + if (missingShapes && !hasLocationId && !hasLocationGroupsRecord && !hasLocationGroupId) { + for (GtfsStopTime stopTime : stopTimeTable.getEntities()) { + noticeContainer.addValidationNotice( + new MissingRecommendedFileNotice("shapes.txt")); + // This is a feed-level warning; emit it at most once. + break; + } + } + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidatorTest.java new file mode 100644 index 0000000000..dd4f0d812b --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidatorTest.java @@ -0,0 +1,121 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFileNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsLocationGroups; +import org.mobilitydata.gtfsvalidator.table.GtfsLocationGroupsTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsShape; +import org.mobilitydata.gtfsvalidator.table.GtfsShapeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; +import org.mobilitydata.gtfsvalidator.table.TableStatus; + +public class MissingShapesFileValidatorTest { + + private static List createShapeTable(int rows) { + ArrayList shapes = new ArrayList<>(); + for (int i = 0; i < rows; i++) { + shapes.add(new GtfsShape.Builder().setCsvRowNumber(i + 1).setShapeId("s" + i).build()); + } + return shapes; + } + + private static List createStopTimesTable( + int rows, String locationGroupId, String locationId) { + ArrayList stopTimes = new ArrayList<>(); + for (int i = 0; i < rows; i++) { + stopTimes.add( + new GtfsStopTime.Builder() + .setCsvRowNumber(i + 1) + .setLocationGroupId(locationGroupId) + .setLocationId(locationId) + .setTripId(locationGroupId) + .setStopSequence(i + 1) + .build()); + } + return stopTimes; + } + + private static List createLocationGroupsTable( + int rows, String groupId, String groupName) { + ArrayList locationGroups = new ArrayList<>(); + for (int i = 0; i < rows; i++) { + locationGroups.add( + new GtfsLocationGroups.Builder() + .setCsvRowNumber(i + 1) + .setLocationGroupId(groupId) + .setLocationGroupName(groupName) + .build()); + } + return locationGroups; + } + + @Test + public void testShapesFileAndFixedDrtPresent() { + List notices = + generateNotices( + createShapeTable(1), + GtfsShapeTableContainer.forStatus(null), + createStopTimesTable(1, "a", null), + createLocationGroupsTable(1, "b", "testgroup")); + boolean found = + notices.stream() + .anyMatch( + notice -> + notice instanceof MissingRecommendedFileNotice); + assertThat(found).isFalse(); + } + + @Test + public void testShapesFileAndZoneBasedDrtPresent() { + List notices = + generateNotices( + createShapeTable(1), + GtfsShapeTableContainer.forStatus(null), + createStopTimesTable(1, null, "c"), + createLocationGroupsTable(1, "d", "t3stgroup")); + boolean found = + notices.stream() + .anyMatch( + notice -> + notice instanceof MissingRecommendedFileNotice); + assertThat(found).isFalse(); + } + + @Test + public void testNoShapesFileAndNoDrtPresent() { + List notices = + generateNotices( + createShapeTable(0), + GtfsShapeTableContainer.forStatus(TableStatus.MISSING_FILE), + createStopTimesTable(1, null, null), + createLocationGroupsTable(0, null, null)); + long missingRecommendedFileNoticesCount = + notices.stream() + .filter( + notice -> + notice instanceof MissingRecommendedFileNotice) + .count(); + assertThat(missingRecommendedFileNoticesCount).isAtLeast(1); + } + + private static List generateNotices( + List shapes, + GtfsShapeTableContainer shapeContainer, + List stopTimes, + List locationGroups) { + NoticeContainer noticeContainer = new NoticeContainer(); + new MissingShapesFileValidator( + GtfsShapeTableContainer.forEntities(shapes, noticeContainer), + GtfsStopTimeTableContainer.forEntities(stopTimes, noticeContainer), + GtfsLocationGroupsTableContainer.forEntities(locationGroups, noticeContainer)) + .validate(noticeContainer); + return noticeContainer.getValidationNotices(); + } +}