diff --git a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/Notice.java b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/Notice.java index ac7622b469..2433de5c85 100644 --- a/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/Notice.java +++ b/core/src/main/java/org/mobilitydata/gtfsvalidator/notice/Notice.java @@ -25,7 +25,11 @@ import com.google.gson.JsonPrimitive; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; +import java.lang.reflect.Field; import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.mobilitydata.gtfsvalidator.type.GtfsColor; import org.mobilitydata.gtfsvalidator.type.GtfsDate; @@ -48,6 +52,12 @@ public JsonElement toJsonTree() { return GSON.toJsonTree(this); } + public List getAllFields() { + return Arrays.stream(this.getClass().getDeclaredFields()) + .map(Field::getName) // Extract the name of each field + .collect(Collectors.toList()); // Collect as a list of strings + } + /** * Returns a descriptive type-specific name for this notice based on the class simple name. * diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java index 3d72409b8a..41452e1590 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/HtmlReportGenerator.java @@ -19,8 +19,13 @@ import java.io.FileWriter; import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; import org.mobilitydata.gtfsvalidator.report.model.FeedMetadata; +import org.mobilitydata.gtfsvalidator.report.model.NoticeView; import org.mobilitydata.gtfsvalidator.report.model.ReportSummary; import org.mobilitydata.gtfsvalidator.runner.ValidationRunnerConfig; import org.mobilitydata.gtfsvalidator.util.VersionInfo; @@ -56,9 +61,38 @@ public void generateReport( context.setVariable("config", config); context.setVariable("date", date); context.setVariable("is_different_date", is_different_date); + context.setVariable( + "uniqueFieldsByCode", + getUniqueFieldsForCodes( + summary.getNoticesMap().values().stream() + .flatMap(map -> map.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)))); try (FileWriter writer = new FileWriter(reportPath.toFile())) { templateEngine.process("report.html", context, writer); } } + + private Map> getUniqueFieldsForCodes( + Map> noticesByCode) { + return noticesByCode.entrySet().stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, // Notice code + entry -> { + // Collect unique fields from all notices for this code + List uniqueFields = + entry.getValue().stream() + .flatMap(notice -> notice.getFields().stream()) + .distinct() + .collect(Collectors.toList()); + + // Start with all fields from the first notice and filter based on unique fields + List filteredFields = + new ArrayList<>(entry.getValue().get(0).getAllFields()); + filteredFields.removeIf(field -> !uniqueFields.contains(field)); + + return filteredFields; + })); + } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/NoticeView.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/NoticeView.java index b6047f0d90..d86c3431e2 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/NoticeView.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/report/model/NoticeView.java @@ -97,4 +97,13 @@ public String getDescription() { public String getCode() { return notice.getContext().getCode(); } + + /** + * Returns a list of all fields in the notice. + * + * @return list of all fields in the notice. + */ + public List getAllFields() { + return notice.getContext().getAllFields(); + } } diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidator.java new file mode 100644 index 0000000000..0bcb0dd807 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidator.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024 MobilityData + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidationNotice; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +/** + * Validates pickup and drop-off windows in the `stop_times.txt` file to ensure compliance with GTFS + * rules. + * + *

This validator checks for: - Forbidden use of arrival or departure times when pickup or + * drop-off windows are provided. - Missing start or end pickup/drop-off windows when one of them is + * present. - Invalid pickup/drop-off windows where the end time is not strictly later than the + * start time. + * + *

Generated notices include: - {@link ForbiddenArrivalOrDepartureTimeNotice} - {@link + * MissingPickupOrDropOffWindowNotice} - {@link InvalidPickupDropOffWindowNotice} + */ +@GtfsValidator +public class PickupDropOffWindowValidator extends SingleEntityValidator { + + @Override + public void validate(GtfsStopTime stopTime, NoticeContainer noticeContainer) { + // Skip validation if neither start nor end pickup/drop-off window is present + if (!stopTime.hasStartPickupDropOffWindow() && !stopTime.hasEndPickupDropOffWindow()) { + return; + } + + // Check for forbidden coexistence of arrival/departure times with pickup/drop-off windows + if (stopTime.hasArrivalTime() || stopTime.hasDepartureTime()) { + noticeContainer.addValidationNotice( + new ForbiddenArrivalOrDepartureTimeNotice( + stopTime.csvRowNumber(), + stopTime.hasArrivalTime() ? stopTime.arrivalTime() : null, + stopTime.hasDepartureTime() ? stopTime.departureTime() : null, + stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null, + stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null)); + } + + // Check for missing start or end pickup/drop-off window + if (!stopTime.hasStartPickupDropOffWindow() || !stopTime.hasEndPickupDropOffWindow()) { + noticeContainer.addValidationNotice( + new MissingPickupOrDropOffWindowNotice( + stopTime.csvRowNumber(), + stopTime.hasStartPickupDropOffWindow() ? stopTime.startPickupDropOffWindow() : null, + stopTime.hasEndPickupDropOffWindow() ? stopTime.endPickupDropOffWindow() : null)); + return; + } + + // Check for invalid pickup/drop-off window (start time must be strictly before end time) + if (stopTime.startPickupDropOffWindow().isAfter(stopTime.endPickupDropOffWindow()) + || stopTime.startPickupDropOffWindow().equals(stopTime.endPickupDropOffWindow())) { + noticeContainer.addValidationNotice( + new InvalidPickupDropOffWindowNotice( + stopTime.csvRowNumber(), + stopTime.startPickupDropOffWindow(), + stopTime.endPickupDropOffWindow())); + } + } + + @Override + public boolean shouldCallValidate(ColumnInspector header) { + // No point in validating if there is no start_pickup_drop_off_window column + // and no end_pickup_drop_off_window column + return header.hasColumn(GtfsStopTime.START_PICKUP_DROP_OFF_WINDOW_FIELD_NAME) + || header.hasColumn(GtfsStopTime.END_PICKUP_DROP_OFF_WINDOW_FIELD_NAME); + } + + /** + * The arrival or departure times are provided alongside pickup or drop-off windows in + * `stop_times.txt`. + * + *

This violates GTFS specification, as both cannot coexist for a single stop time record. + */ + @GtfsValidationNotice(severity = ERROR) + public static class ForbiddenArrivalOrDepartureTimeNotice extends ValidationNotice { + + /** The row of the faulty record. */ + private final int csvRowNumber; + + /** The arrival time of the faulty record. */ + private final GtfsTime arrivalTime; + + /** The departure time of the faulty record. */ + private final GtfsTime departureTime; + + /** The start pickup drop off window of the faulty record. */ + private final GtfsTime startPickupDropOffWindow; + + /** The end pickup drop off window of the faulty record. */ + private final GtfsTime endPickupDropOffWindow; + + public ForbiddenArrivalOrDepartureTimeNotice( + int csvRowNumber, + GtfsTime arrivalTime, + GtfsTime departureTime, + GtfsTime startPickupDropOffWindow, + GtfsTime endPickupDropOffWindow) { + this.csvRowNumber = csvRowNumber; + this.arrivalTime = arrivalTime; + this.departureTime = departureTime; + this.startPickupDropOffWindow = startPickupDropOffWindow; + this.endPickupDropOffWindow = endPickupDropOffWindow; + } + } + + /** + * Either the start or end pickup/drop-off window is missing in `stop_times.txt`. + * + *

GTFS specification requires both the start and end pickup/drop-off windows to be provided + * together, if used. + */ + @GtfsValidationNotice(severity = ERROR) + public static class MissingPickupOrDropOffWindowNotice extends ValidationNotice { + /** The row of the faulty record. */ + private final int csvRowNumber; + + /** The start pickup drop off window of the faulty record. */ + private final GtfsTime startPickupDropOffWindow; + + /** The end pickup drop off window of the faulty record. */ + private final GtfsTime endPickupDropOffWindow; + + public MissingPickupOrDropOffWindowNotice( + int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) { + this.csvRowNumber = csvRowNumber; + this.startPickupDropOffWindow = startPickupDropOffWindow; + this.endPickupDropOffWindow = endPickupDropOffWindow; + } + } + + /** + * The pickup/drop-off window in `stop_times.txt` is invalid. + * + *

The `end_pickup_drop_off_window` must be strictly later than the + * `start_pickup_drop_off_window`. + */ + @GtfsValidationNotice(severity = ERROR) + public static class InvalidPickupDropOffWindowNotice extends ValidationNotice { + /** The row of the faulty record. */ + private final int csvRowNumber; + + /** The start pickup drop off window of the faulty record. */ + private final GtfsTime startPickupDropOffWindow; + + /** The end pickup drop off window of the faulty record. */ + private final GtfsTime endPickupDropOffWindow; + + public InvalidPickupDropOffWindowNotice( + int csvRowNumber, GtfsTime startPickupDropOffWindow, GtfsTime endPickupDropOffWindow) { + this.csvRowNumber = csvRowNumber; + this.startPickupDropOffWindow = startPickupDropOffWindow; + this.endPickupDropOffWindow = endPickupDropOffWindow; + } + } +} diff --git a/main/src/main/resources/report.html b/main/src/main/resources/report.html index f01ae6f30a..78c73bba71 100644 --- a/main/src/main/resources/report.html +++ b/main/src/main/resources/report.html @@ -368,9 +368,9 @@

- + - - - + +
- + (?) @@ -379,10 +379,10 @@

- +
+
diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidatorTest.java new file mode 100644 index 0000000000..722015190a --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/PickupDropOffWindowValidatorTest.java @@ -0,0 +1,81 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsStopTime; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@RunWith(JUnit4.class) +public class PickupDropOffWindowValidatorTest { + @Test + public void shouldGenerateForbiddenArrivalOrDepartureTimeNotice() { + NoticeContainer noticeContainer = new NoticeContainer(); + PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator(); + + GtfsStopTime stopTime = + new GtfsStopTime.Builder() + .setCsvRowNumber(1) + .setArrivalTime(GtfsTime.fromString("00:00:00")) + .setDepartureTime(GtfsTime.fromString("00:00:01")) + .setStartPickupDropOffWindow(GtfsTime.fromString("00:00:02")) + .setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03")) + .build(); + validator.validate(stopTime, noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(1); + assertThat(noticeContainer.getValidationNotices().stream().findFirst().get()) + .isInstanceOf(PickupDropOffWindowValidator.ForbiddenArrivalOrDepartureTimeNotice.class); + } + + @Test + public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingStart() { + NoticeContainer noticeContainer = new NoticeContainer(); + PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator(); + + GtfsStopTime stopTime = + new GtfsStopTime.Builder() + .setCsvRowNumber(1) + .setEndPickupDropOffWindow(GtfsTime.fromString("00:00:03")) + .build(); + validator.validate(stopTime, noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(1); + assertThat(noticeContainer.getValidationNotices().stream().findFirst().get()) + .isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class); + } + + @Test + public void shouldGenerateMissingPickupOrDropOffWindowNotice_missingEnd() { + NoticeContainer noticeContainer = new NoticeContainer(); + PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator(); + + GtfsStopTime stopTime = + new GtfsStopTime.Builder() + .setCsvRowNumber(1) + .setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03")) + .build(); + validator.validate(stopTime, noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(1); + assertThat(noticeContainer.getValidationNotices().stream().findFirst().get()) + .isInstanceOf(PickupDropOffWindowValidator.MissingPickupOrDropOffWindowNotice.class); + } + + @Test + public void shouldGenerateInvalidPickupDropOffWindowNotice() { + NoticeContainer noticeContainer = new NoticeContainer(); + PickupDropOffWindowValidator validator = new PickupDropOffWindowValidator(); + + GtfsStopTime stopTime = + new GtfsStopTime.Builder() + .setCsvRowNumber(1) + .setStartPickupDropOffWindow(GtfsTime.fromString("00:00:03")) + .setEndPickupDropOffWindow(GtfsTime.fromString("00:00:02")) + .build(); + validator.validate(stopTime, noticeContainer); + assertThat(noticeContainer.getValidationNotices()).hasSize(1); + assertThat(noticeContainer.getValidationNotices().stream().findFirst().get()) + .isInstanceOf(PickupDropOffWindowValidator.InvalidPickupDropOffWindowNotice.class); + } +}