Skip to content

Commit

Permalink
Merge e3927f6 into 9f06d68
Browse files Browse the repository at this point in the history
  • Loading branch information
cka-y authored Dec 24, 2024
2 parents 9f06d68 + e3927f6 commit 587efd4
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,6 +52,12 @@ public JsonElement toJsonTree() {
return GSON.toJsonTree(this);
}

public List<String> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, List<String>> getUniqueFieldsForCodes(
Map<String, List<NoticeView>> noticesByCode) {
return noticesByCode.entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey, // Notice code
entry -> {
// Collect unique fields from all notices for this code
List<String> 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<String> filteredFields =
new ArrayList<>(entry.getValue().get(0).getAllFields());
filteredFields.removeIf(field -> !uniqueFields.contains(field));

return filteredFields;
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> getAllFields() {
return notice.getContext().getAllFields();
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>Generated notices include: - {@link ForbiddenArrivalOrDepartureTimeNotice} - {@link
* MissingPickupOrDropOffWindowNotice} - {@link InvalidPickupDropOffWindowNotice}
*/
@GtfsValidator
public class PickupDropOffWindowValidator extends SingleEntityValidator<GtfsStopTime> {

@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`.
*
* <p>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`.
*
* <p>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.
*
* <p>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;
}
}
}
12 changes: 6 additions & 6 deletions main/src/main/resources/report.html
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,9 @@ <h3 th:text="${noticesByCode.key}" />
<table>
<thead>
<tr>
<th:block th:each="field: ${noticesByCode.value[0].fields}">
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
<th>
<span th:text="${field}"></span>
<span th:text="${field}"></span>
<a href="#" class="tooltip" onclick="event.preventDefault();"><span>(?)</span>
<span class="tooltiptext" th:text="${noticesByCode.value[0].getCommentForField(field)}"></span>
</a>
Expand All @@ -379,10 +379,10 @@ <h3 th:text="${noticesByCode.key}" />
</tr>
</thead>
<tbody>
<tr th:each="notice, iStat : ${noticesByCode.value}" th:if="${iStat.count <= 50}">
<th:block th:each="field: ${notice.fields}">
<td th:text="${notice.getValueForField(field)}" />
</th:block>
<tr th:each="notice: ${noticesByCode.value}">
<th:block th:each="field: ${uniqueFieldsByCode[noticesByCode.key]}">
<td th:text="${notice.getFields().contains(field) ? notice.getValueForField(field) : 'N/A'}" />
</th:block>
</tr>
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public void testNoticeClassFieldNames() {
"departureTime1",
"distanceKm",
"endFieldName",
"endPickupDropOffWindow",
"endValue",
"entityCount",
"entityId",
Expand Down Expand Up @@ -182,6 +183,7 @@ public void testNoticeClassFieldNames() {
"specifiedField",
"speedKph",
"startFieldName",
"startPickupDropOffWindow",
"startValue",
"stopCsvRowNumber",
"stopDesc",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}

0 comments on commit 587efd4

Please sign in to comment.