diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/model/FeedMetadata.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/model/FeedMetadata.java index 26f102215a..49b01db7f2 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/model/FeedMetadata.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/reportsummary/model/FeedMetadata.java @@ -183,8 +183,8 @@ private int loadUniqueCount( * "Zone-Based Demand Responsive Transit" feature. * @return true if at least one trip with only location_id is found, false otherwise. */ - private boolean hasAtLeastOneTripWithOnlyLocationId(GtfsFeedContainer feedContainer) { - var optionalStopTimeTable = feedContainer.getTableForFilename(GtfsStopTime.FILENAME); + public static boolean hasAtLeastOneTripWithOnlyLocationId(E extendedTable, FeedContainer feedContainer) { + var optionalStopTimeTable = extendedTable.getTableForFilename(GtfsStopTime.FILENAME); if (optionalStopTimeTable.isPresent()) { for (GtfsEntity entity : optionalStopTimeTable.get().getEntities()) { if (entity instanceof GtfsStopTime) { @@ -206,8 +206,8 @@ private boolean hasAtLeastOneTripWithOnlyLocationId(GtfsFeedContainer feedContai * "Fixed-Stops Demand Responsive Transit" feature. * @return true if at least one trip with only location_group_id is found, false otherwise. */ - private boolean hasAtLeastOneTripWithOnlyLocationGroupId(GtfsFeedContainer feedContainer) { - var optionalStopTimeTable = feedContainer.getTableForFilename(GtfsStopTime.FILENAME); + public static boolean hasAtLeastOneTripWithOnlyLocationGroupId(E extendedTable, GtfsFeedContainer feedContainer) { + var optionalStopTimeTable = extendedTable.getTableForFilename(GtfsStopTime.FILENAME); if (optionalStopTimeTable.isPresent()) { for (GtfsEntity entity : optionalStopTimeTable.get().getEntities()) { if (entity instanceof GtfsStopTime) { @@ -791,9 +791,9 @@ public void loadServiceWindow( } } - private boolean hasAtLeastOneRecordInFile( - GtfsFeedContainer feedContainer, String featureFilename) { - var table = feedContainer.getTableForFilename(featureFilename); + public static boolean hasAtLeastOneRecordInFile( + E extendedTable, GtfsFeedContainer feedContainer, String featureFilename) { + var table = extendedTable.getTableForFilename(featureFilename); return table.isPresent() && table.get().entityCount() > 0; } 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..afc1dde086 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidator.java @@ -0,0 +1,69 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.MissingRecommendedFileNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsLocationGroupsTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsShapeTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTimeTableContainer; + +/** + * 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(); + if (!missingShapes) { + return; + } + + 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) { + noticeContainer.addValidationNotice(new MissingRecommendedFileNotice("shapes.txt")); + // This is a feed-level warning; emit it at most once. + return; + } + } +} 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..c478a2d259 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/MissingShapesFileValidatorTest.java @@ -0,0 +1,109 @@ +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; + +public class MissingShapesFileValidatorTest { + + private static List createShapeTable(int rows) { + ArrayList shapes = new ArrayList<>(); + for (int i = 0; i < rows; i++) { + if (rows == -1) { + return null; + } + 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), + 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), + 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(-1), + 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, + 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(); + } +}