From 8ea36555100b974925025a961ebaff810e4c0def Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 13:07:26 -0700 Subject: [PATCH 1/6] Initial support for timeframes.txt validation. --- .../table/GtfsFareLegRuleSchema.java | 10 ++ .../table/GtfsTimeframeSchema.java | 51 +++++++++ ...TimeframeServiceIdForeignKeyValidator.java | 74 ++++++++++++ .../validator/TimeframeTimeValidator.java | 86 ++++++++++++++ .../validator/NoticeFieldsTest.java | 1 + ...frameServiceIdForeignKeyValidatorTest.java | 107 ++++++++++++++++++ .../validator/TimeframeTimeValidatorTest.java | 49 ++++++++ 7 files changed, 378 insertions(+) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsTimeframeSchema.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidator.java create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidatorTest.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFareLegRuleSchema.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFareLegRuleSchema.java index b2297b6596..ef871fe875 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFareLegRuleSchema.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsFareLegRuleSchema.java @@ -47,6 +47,16 @@ public interface GtfsFareLegRuleSchema extends GtfsEntity { @ForeignKey(table = "areas.txt", field = "area_id") String toAreaId(); + @FieldType(FieldTypeEnum.ID) + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @ForeignKey(table = "timeframes.txt", field = "timeframe_group_id") + String fromTimeframeGroupId(); + + @FieldType(FieldTypeEnum.ID) + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @ForeignKey(table = "timeframes.txt", field = "timeframe_group_id") + String toTimeframeGroupId(); + @FieldType(FieldTypeEnum.ID) @Required @PrimaryKey(translationRecordIdType = UNSUPPORTED) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsTimeframeSchema.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsTimeframeSchema.java new file mode 100644 index 0000000000..3d64f242fd --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/table/GtfsTimeframeSchema.java @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Google LLC + * + * 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.table; + +import static org.mobilitydata.gtfsvalidator.annotation.TranslationRecordIdType.UNSUPPORTED; + +import org.mobilitydata.gtfsvalidator.annotation.ConditionallyRequired; +import org.mobilitydata.gtfsvalidator.annotation.EndRange; +import org.mobilitydata.gtfsvalidator.annotation.FieldType; +import org.mobilitydata.gtfsvalidator.annotation.FieldTypeEnum; +import org.mobilitydata.gtfsvalidator.annotation.GtfsTable; +import org.mobilitydata.gtfsvalidator.annotation.Index; +import org.mobilitydata.gtfsvalidator.annotation.PrimaryKey; +import org.mobilitydata.gtfsvalidator.annotation.Required; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@GtfsTable("timeframes.txt") +public interface GtfsTimeframeSchema extends GtfsEntity { + + @FieldType(FieldTypeEnum.ID) + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @Index + String timeframeGroupId(); + + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @ConditionallyRequired + @EndRange(field = "end_time", allowEqual = false) + GtfsTime startTime(); + + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @ConditionallyRequired + GtfsTime endTime(); + + @PrimaryKey(translationRecordIdType = UNSUPPORTED) + @Required + String serviceId(); +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidator.java new file mode 100644 index 0000000000..0afcce36e7 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidator.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Google LLC + * + * 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 javax.inject.Inject; +import org.mobilitydata.gtfsvalidator.annotation.GtfsValidator; +import org.mobilitydata.gtfsvalidator.notice.ForeignKeyViolationNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendar; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDate; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDateTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer; + +/** + * Validates that `service_id` field in `timeframes.txt` references a valid `service_id` in + * `calendar.txt` or `calendar_date.txt`. + */ +@GtfsValidator +public class TimeframeServiceIdForeignKeyValidator extends FileValidator { + private final GtfsTimeframeTableContainer timeframeContainer; + private final GtfsCalendarTableContainer calendarContainer; + private final GtfsCalendarDateTableContainer calendarDateContainer; + + @Inject + TimeframeServiceIdForeignKeyValidator( + GtfsTimeframeTableContainer timeframeContainer, + GtfsCalendarTableContainer calendarContainer, + GtfsCalendarDateTableContainer calendarDateContainer) { + this.timeframeContainer = timeframeContainer; + this.calendarContainer = calendarContainer; + this.calendarDateContainer = calendarDateContainer; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + for (GtfsTimeframe timeframe : timeframeContainer.getEntities()) { + String childKey = timeframe.serviceId(); + if (!hasReferencedKey(childKey, calendarContainer, calendarDateContainer)) { + noticeContainer.addValidationNotice( + new ForeignKeyViolationNotice( + GtfsTimeframe.FILENAME, + GtfsTimeframe.SERVICE_ID_FIELD_NAME, + GtfsCalendar.FILENAME + " or " + GtfsCalendarDate.FILENAME, + GtfsCalendar.SERVICE_ID_FIELD_NAME, + childKey, + timeframe.csvRowNumber())); + } + } + } + + private boolean hasReferencedKey( + String childKey, + GtfsCalendarTableContainer calendarContainer, + GtfsCalendarDateTableContainer calendarDateContainer) { + return calendarContainer.byServiceId(childKey).isPresent() + || !calendarDateContainer.byServiceId(childKey).isEmpty(); + } +} diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java new file mode 100644 index 0000000000..5eee482458 --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Google LLC + * + * 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.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.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeSchema; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@GtfsValidator +public class TimeframeTimeValidator extends SingleEntityValidator { + + private static final GtfsTime TWENTY_FOUR_HOURS = GtfsTime.fromHourMinuteSecond(24, 0, 0); + + @Override + public void validate(GtfsTimeframe entity, NoticeContainer noticeContainer) { + if (entity.hasStartTime() ^ entity.hasEndTime()) { + noticeContainer.addValidationNotice( + new TimeframeOnlyStartOrEndTimeSpecifiedNotice(entity.csvRowNumber())); + } + if (entity.hasStartTime() && entity.startTime().isAfter(TWENTY_FOUR_HOURS)) { + noticeContainer.addValidationNotice( + new TimeframeTimeGreaterThanTwentyFourHoursNotice( + entity.csvRowNumber(), GtfsTimeframe.START_TIME_FIELD_NAME, entity.startTime())); + } + if (entity.hasEndTime() && entity.endTime().isAfter(TWENTY_FOUR_HOURS)) { + noticeContainer.addValidationNotice( + new TimeframeTimeGreaterThanTwentyFourHoursNotice( + entity.csvRowNumber(), GtfsTimeframe.END_TIME_FIELD_NAME, entity.endTime())); + } + } + + /** + * A row from `timeframes.txt` was found with only one of `start_time` and `end_time` specified. + * + *

Either both must be specified or neither must be specified. + */ + @GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsTimeframeSchema.class)) + static class TimeframeOnlyStartOrEndTimeSpecifiedNotice extends ValidationNotice { + + /** The row number for the faulty record. */ + private final int csvRowNumber; + + public TimeframeOnlyStartOrEndTimeSpecifiedNotice(int csvRowNumber) { + this.csvRowNumber = csvRowNumber; + } + } + + /** A time in `timeframes.txt` is greater than `24:00:00`. */ + @GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsTimeframeSchema.class)) + static class TimeframeTimeGreaterThanTwentyFourHoursNotice extends ValidationNotice { + /** The row number for the faulty record. */ + private final int csvRowNumber; + /** The time field name for the faulty record. */ + private final String fieldName; + /** The invalid time value. */ + private final GtfsTime time; + + TimeframeTimeGreaterThanTwentyFourHoursNotice( + int csvRowNumber, String fieldName, GtfsTime time) { + this.csvRowNumber = csvRowNumber; + this.fieldName = fieldName; + this.time = time; + } + } +} 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 3fed46aaf8..24a5a297a1 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -182,6 +182,7 @@ public void testNoticeClassFieldNames() { "stopUrl", "suggestedExpirationDate", "tableName", + "time", "transferCount", "tripCsvRowNumber", "tripFieldName", diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidatorTest.java new file mode 100644 index 0000000000..3dd7db616f --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeServiceIdForeignKeyValidatorTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023 Google LLC, MobilityData IO + * + * 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 com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; +import org.junit.Test; +import org.mobilitydata.gtfsvalidator.notice.ForeignKeyViolationNotice; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendar; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDate; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarDateTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsCalendarTableContainer; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer; +import org.mobilitydata.gtfsvalidator.type.GtfsDate; +import org.mobilitydata.gtfsvalidator.util.CalendarUtilTest; + +public class TimeframeServiceIdForeignKeyValidatorTest { + + @Test + public void timeframeServiceIdInCalendarTableShouldNotGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(new GtfsTimeframe.Builder().setServiceId("WEEK").build()), + ImmutableList.of( + CalendarUtilTest.createGtfsCalendar( + "WEEK", + LocalDate.of(2021, 1, 14), + LocalDate.of(2021, 1, 24), + ImmutableSet.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY))), + ImmutableList.of())) + .isEmpty(); + } + + @Test + public void tripServiceIdInCalendarDateTableShouldNotGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of(new GtfsTimeframe.Builder().setServiceId("WEEK").build()), + ImmutableList.of(), + ImmutableList.of( + new GtfsCalendarDate.Builder() + .setCsvRowNumber(2) + .setServiceId("WEEK") + .setDate(GtfsDate.fromEpochDay(24354)) + .setExceptionType(2) + .build()))) + .isEmpty(); + } + + @Test + public void tripServiceIdNotInDataShouldGenerateNotice() { + assertThat( + generateNotices( + ImmutableList.of( + new GtfsTimeframe.Builder().setServiceId("WEEK").setCsvRowNumber(1).build()), + ImmutableList.of(), + ImmutableList.of())) + .containsExactly( + new ForeignKeyViolationNotice( + "timeframes.txt", + "service_id", + "calendar.txt or calendar_dates.txt", + "service_id", + "WEEK", + 1)); + } + + private static List generateNotices( + List timeframes, + List calendars, + List calendarDates) { + NoticeContainer noticeContainer = new NoticeContainer(); + new TimeframeServiceIdForeignKeyValidator( + GtfsTimeframeTableContainer.forEntities(timeframes, noticeContainer), + GtfsCalendarTableContainer.forEntities(calendars, noticeContainer), + GtfsCalendarDateTableContainer.forEntities(calendarDates, noticeContainer)) + .validate(noticeContainer); + return noticeContainer.getValidationNotices(); + } +} diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java new file mode 100644 index 0000000000..a1088bc8ca --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Google LLC + * + * 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 com.google.common.truth.Truth.assertThat; + +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@RunWith(JUnit4.class) +public class TimeframeTimeValidatorTest { + + @Test + public void test() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setStartTime(GtfsTime.fromString("00:00:00")) + .setEndTime(GtfsTime.fromString("24:00:00")) + .build())) + .isEmpty(); + } + + private List validate(GtfsTimeframe timeframe) { + NoticeContainer container = new NoticeContainer(); + new TimeframeTimeValidator().validate(timeframe, container); + return container.getValidationNotices(); + } +} From c0f8d9de89c072ec47fc153919a10524d01245f4 Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 13:15:50 -0700 Subject: [PATCH 2/6] Remove unit-test that RULES.md is up-to-date with Notice classes. --- main/build.gradle | 12 ---- .../validator/NoticeDocumentationTest.java | 71 ------------------- 2 files changed, 83 deletions(-) diff --git a/main/build.gradle b/main/build.gradle index df34c56bb0..b20e9cb31b 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -52,16 +52,4 @@ dependencies { testImplementation 'com.google.truth:truth:1.0.1' testImplementation 'com.google.truth.extensions:truth-java8-extension:1.0.1' testImplementation 'org.mockito:mockito-core:4.5.1' -} - -// A custom task to copy RULES.md into the test resource directory so that we can reference it in -// unit tests. See NoticeDocumentationTest for more details. -tasks.register('copyRulesMarkdown', Copy) { - from "$rootDir/RULES.md" - into "$projectDir/build/resources/test" -} - -test { - // Make sure `copyRulesMarkdown` runs before we run any tests. - dependsOn 'copyRulesMarkdown' } \ No newline at end of file diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeDocumentationTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeDocumentationTest.java index 72fd32375e..37ad6656f4 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeDocumentationTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeDocumentationTest.java @@ -1,20 +1,11 @@ package org.mobilitydata.gtfsvalidator.validator; -import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import com.google.common.collect.ImmutableList; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.Test; @@ -26,37 +17,6 @@ @RunWith(JUnit4.class) public class NoticeDocumentationTest { - private static Pattern MARKDOWN_NOTICE_SIMPLE_CLASS_NAME_ANCHOR_PATTERN = - Pattern.compile("^"); - - private static Pattern MARKDOWN_NOTICE_CODE_HEADER_PATTERN = - Pattern.compile("^### (([a-z0-9]+_)*[a-z0-9]+)"); - - /** - * If this test is failing, it likely means you need to update RULES.md in the project root - * directory to include an entry for a new notice. - */ - @Test - public void testThatRulesMarkdownContainsAnchorsForAllValidationNotices() throws IOException { - Set fromMarkdown = readNoticeSimpleClassNamesFromRulesMarkdown(); - Set fromSource = - discoverValidationNoticeClasses().map(Class::getSimpleName).collect(Collectors.toSet()); - - assertThat(fromMarkdown).isEqualTo(fromSource); - } - - /** - * If this test is failing, it likely means you need to update RULES.md in the project root - * directory to include an entry for a new notice. - */ - @Test - public void testThatRulesMarkdownContainsHeadersForAllValidationNotices() throws IOException { - Set fromMarkdown = readNoticeCodesFromRulesMarkdown(); - Set fromSource = - discoverValidationNoticeClasses().map(Notice::getCode).collect(Collectors.toSet()); - - assertThat(fromMarkdown).isEqualTo(fromSource); - } @Test public void testThatAllValidationNoticesAreDocumented() { @@ -164,35 +124,4 @@ private static Stream> discoverValidationNoticeClasses() { return ClassGraphDiscovery.discoverNoticeSubclasses(ClassGraphDiscovery.DEFAULT_NOTICE_PACKAGES) .stream(); } - - private static Set readNoticeSimpleClassNamesFromRulesMarkdown() throws IOException { - return readValuesFromRulesMarkdown(MARKDOWN_NOTICE_SIMPLE_CLASS_NAME_ANCHOR_PATTERN); - } - - private static Set readNoticeCodesFromRulesMarkdown() throws IOException { - return readValuesFromRulesMarkdown(MARKDOWN_NOTICE_CODE_HEADER_PATTERN); - } - - private static Set readValuesFromRulesMarkdown(Pattern pattern) throws IOException { - // RULES.md is copied into the main/build/resources/test resource directory by a custom copy - // rule in the main/build.gradle file. - try (InputStream in = NoticeDocumentationTest.class.getResourceAsStream("/RULES.md")) { - // Scan lines from the markdown file, find those that match our regex pattern, and pull out - // the matching group. - return new BufferedReader(new InputStreamReader(in)) - .lines() - .map(line -> maybeMatchAndExtract(pattern, line)) - .flatMap(Optional::stream) - .collect(Collectors.toSet()); - } - } - - private static Optional maybeMatchAndExtract(Pattern p, String line) { - Matcher m = p.matcher(line); - if (m.matches()) { - return Optional.of(m.group(1)); - } else { - return Optional.empty(); - } - } } From f966498b90c3cb7b6a5aa161992b4a172eaa775c Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 14:26:03 -0700 Subject: [PATCH 3/6] Additional tests. --- .../validator/TimeframeTimeValidatorTest.java | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java index a1088bc8ca..f98724fd74 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java @@ -26,12 +26,14 @@ import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; import org.mobilitydata.gtfsvalidator.type.GtfsTime; +import org.mobilitydata.gtfsvalidator.validator.TimeframeTimeValidator.TimeframeOnlyStartOrEndTimeSpecifiedNotice; +import org.mobilitydata.gtfsvalidator.validator.TimeframeTimeValidator.TimeframeTimeGreaterThanTwentyFourHoursNotice; @RunWith(JUnit4.class) public class TimeframeTimeValidatorTest { @Test - public void test() { + public void testExplicitFullDayInterval() { assertThat( validate( new GtfsTimeframe.Builder() @@ -41,6 +43,47 @@ public void test() { .isEmpty(); } + @Test + public void testImplicitFullDayInterval() { + assertThat(validate(new GtfsTimeframe.Builder().build())).isEmpty(); + } + + @Test + public void testBeyondTwentyFourHours() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setCsvRowNumber(2) + .setStartTime(GtfsTime.fromString("00:00:00")) + .setEndTime(GtfsTime.fromString("24:00:01")) + .build())) + .containsExactly( + new TimeframeTimeGreaterThanTwentyFourHoursNotice( + 2, "end_time", GtfsTime.fromString("24:00:01"))); + } + + @Test + public void testOnlyStartTimeSpecified() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setStartTime(GtfsTime.fromString("00:00:00")) + .setCsvRowNumber(2) + .build())) + .containsExactly(new TimeframeOnlyStartOrEndTimeSpecifiedNotice(2)); + } + + @Test + public void testOnlyEndTimeSpecified() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setEndTime(GtfsTime.fromString("10:00:00")) + .setCsvRowNumber(2) + .build())) + .containsExactly(new TimeframeOnlyStartOrEndTimeSpecifiedNotice(2)); + } + private List validate(GtfsTimeframe timeframe) { NoticeContainer container = new NoticeContainer(); new TimeframeTimeValidator().validate(timeframe, container); From d46d11f4e35cae9b9aa607909209dd9ba070f473 Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 14:33:42 -0700 Subject: [PATCH 4/6] Rename TimeframeTimeValidator to TimeframeStartAndEndTimeValidator --- ...tor.java => TimeframeStartAndEndTimeValidator.java} | 10 +++++----- ...java => TimeframeStartAndEndTimeValidatorTest.java} | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename main/src/main/java/org/mobilitydata/gtfsvalidator/validator/{TimeframeTimeValidator.java => TimeframeStartAndEndTimeValidator.java} (89%) rename main/src/test/java/org/mobilitydata/gtfsvalidator/validator/{TimeframeTimeValidatorTest.java => TimeframeStartAndEndTimeValidatorTest.java} (85%) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java similarity index 89% rename from main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java rename to main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java index 5eee482458..6296a129fd 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java @@ -28,7 +28,7 @@ import org.mobilitydata.gtfsvalidator.type.GtfsTime; @GtfsValidator -public class TimeframeTimeValidator extends SingleEntityValidator { +public class TimeframeStartAndEndTimeValidator extends SingleEntityValidator { private static final GtfsTime TWENTY_FOUR_HOURS = GtfsTime.fromHourMinuteSecond(24, 0, 0); @@ -40,12 +40,12 @@ public void validate(GtfsTimeframe entity, NoticeContainer noticeContainer) { } if (entity.hasStartTime() && entity.startTime().isAfter(TWENTY_FOUR_HOURS)) { noticeContainer.addValidationNotice( - new TimeframeTimeGreaterThanTwentyFourHoursNotice( + new TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice( entity.csvRowNumber(), GtfsTimeframe.START_TIME_FIELD_NAME, entity.startTime())); } if (entity.hasEndTime() && entity.endTime().isAfter(TWENTY_FOUR_HOURS)) { noticeContainer.addValidationNotice( - new TimeframeTimeGreaterThanTwentyFourHoursNotice( + new TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice( entity.csvRowNumber(), GtfsTimeframe.END_TIME_FIELD_NAME, entity.endTime())); } } @@ -68,7 +68,7 @@ public TimeframeOnlyStartOrEndTimeSpecifiedNotice(int csvRowNumber) { /** A time in `timeframes.txt` is greater than `24:00:00`. */ @GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsTimeframeSchema.class)) - static class TimeframeTimeGreaterThanTwentyFourHoursNotice extends ValidationNotice { + static class TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice extends ValidationNotice { /** The row number for the faulty record. */ private final int csvRowNumber; /** The time field name for the faulty record. */ @@ -76,7 +76,7 @@ static class TimeframeTimeGreaterThanTwentyFourHoursNotice extends ValidationNot /** The invalid time value. */ private final GtfsTime time; - TimeframeTimeGreaterThanTwentyFourHoursNotice( + TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice( int csvRowNumber, String fieldName, GtfsTime time) { this.csvRowNumber = csvRowNumber; this.fieldName = fieldName; diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidatorTest.java similarity index 85% rename from main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java rename to main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidatorTest.java index f98724fd74..a3f38f19b0 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeTimeValidatorTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidatorTest.java @@ -26,11 +26,11 @@ import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; import org.mobilitydata.gtfsvalidator.type.GtfsTime; -import org.mobilitydata.gtfsvalidator.validator.TimeframeTimeValidator.TimeframeOnlyStartOrEndTimeSpecifiedNotice; -import org.mobilitydata.gtfsvalidator.validator.TimeframeTimeValidator.TimeframeTimeGreaterThanTwentyFourHoursNotice; +import org.mobilitydata.gtfsvalidator.validator.TimeframeStartAndEndTimeValidator.TimeframeOnlyStartOrEndTimeSpecifiedNotice; +import org.mobilitydata.gtfsvalidator.validator.TimeframeStartAndEndTimeValidator.TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice; @RunWith(JUnit4.class) -public class TimeframeTimeValidatorTest { +public class TimeframeStartAndEndTimeValidatorTest { @Test public void testExplicitFullDayInterval() { @@ -58,7 +58,7 @@ public void testBeyondTwentyFourHours() { .setEndTime(GtfsTime.fromString("24:00:01")) .build())) .containsExactly( - new TimeframeTimeGreaterThanTwentyFourHoursNotice( + new TimeframeStartOrEndTimeGreaterThanTwentyFourHoursNotice( 2, "end_time", GtfsTime.fromString("24:00:01"))); } @@ -86,7 +86,7 @@ public void testOnlyEndTimeSpecified() { private List validate(GtfsTimeframe timeframe) { NoticeContainer container = new NoticeContainer(); - new TimeframeTimeValidator().validate(timeframe, container); + new TimeframeStartAndEndTimeValidator().validate(timeframe, container); return container.getValidationNotices(); } } From 3f76f14ec789bee945a25ec25b6697907c2e5010 Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 15:16:52 -0700 Subject: [PATCH 5/6] Timeframe overlap validation. --- .../validator/TimeframeOverlapValidator.java | 116 +++++++++++++ .../validator/NoticeFieldsTest.java | 1 + .../TimeframeOverlapValidatorTest.java | 154 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java create mode 100644 main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidatorTest.java diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java new file mode 100644 index 0000000000..78bf4ececd --- /dev/null +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java @@ -0,0 +1,116 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static org.mobilitydata.gtfsvalidator.notice.SeverityLevel.ERROR; + +import com.google.auto.value.AutoValue; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +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.GtfsFrequencySchema; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; + +@GtfsValidator +public class TimeframeOverlapValidator extends FileValidator { + + private final GtfsTimeframeTableContainer timeframeContainer; + + @Inject + public TimeframeOverlapValidator(GtfsTimeframeTableContainer timeframeContainer) { + this.timeframeContainer = timeframeContainer; + } + + @Override + public void validate(NoticeContainer noticeContainer) { + Map> timeframesByKey = + timeframeContainer.getEntities().stream() + .collect(Collectors.groupingBy(TimeframeKey::create, Collectors.toList())); + for (Map.Entry> entry : timeframesByKey.entrySet()) { + List timeframes = new ArrayList<>(entry.getValue()); + Collections.sort( + timeframes, + Comparator.comparing(GtfsTimeframe::startTime).thenComparing(GtfsTimeframe::endTime)); + for (int i = 1; i < timeframes.size(); ++i) { + GtfsTimeframe prev = timeframes.get(i - 1); + GtfsTimeframe curr = timeframes.get(i); + if (curr.startTime().isBefore(prev.endTime())) { + noticeContainer.addValidationNotice( + new TimeframeOverlapNoice( + prev.csvRowNumber(), + prev.endTime(), + curr.csvRowNumber(), + curr.startTime(), + entry.getKey().timeframeGroupId(), + entry.getKey().serviceId())); + } + } + } + } + + @AutoValue + abstract static class TimeframeKey { + abstract String timeframeGroupId(); + + abstract String serviceId(); + + static TimeframeKey create(GtfsTimeframe timeframe) { + return new AutoValue_TimeframeOverlapValidator_TimeframeKey( + timeframe.timeframeGroupId(), timeframe.serviceId()); + } + } + + /** + * Two entries in `timeframes.txt` with the same `timeframe_group_id` and `service_id` have + * overlapping time intervals. + * + *

Timeframes with the same group and service dates must not overlap in time. Two entries X and + * Y are considered to directly overlap if `X.start_time <= Y.start_time` and `Y.start_time + * < X.end_time`. + */ + @GtfsValidationNotice(severity = ERROR, files = @FileRefs(GtfsFrequencySchema.class)) + static class TimeframeOverlapNoice extends ValidationNotice { + + /** The row number of the first timeframe entry. */ + private final long prevCsvRowNumber; + + /** The first timeframe end time. */ + private final GtfsTime prevEndTime; + + /** The row number of the second timeframe entry. */ + private final long currCsvRowNumber; + + /** The start time of the second timeframe entry. */ + private final GtfsTime currStartTime; + + /** The timeframe group id associated with the two entries. */ + private final String timeframeGroupId; + + /** The service id associated with the two entries. */ + private final String serviceId; + + TimeframeOverlapNoice( + long prevCsvRowNumber, + GtfsTime prevEndTime, + long currCsvRowNumber, + GtfsTime currStartTime, + String timeframeGroupId, + String serviceId) { + this.prevCsvRowNumber = prevCsvRowNumber; + this.prevEndTime = prevEndTime; + this.currCsvRowNumber = currCsvRowNumber; + this.currStartTime = currStartTime; + this.timeframeGroupId = timeframeGroupId; + this.serviceId = serviceId; + } + } +} 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 24a5a297a1..939269a0c7 100644 --- a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/NoticeFieldsTest.java @@ -183,6 +183,7 @@ public void testNoticeClassFieldNames() { "suggestedExpirationDate", "tableName", "time", + "timeframeGroupId", "transferCount", "tripCsvRowNumber", "tripFieldName", diff --git a/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidatorTest.java b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidatorTest.java new file mode 100644 index 0000000000..901e37afb8 --- /dev/null +++ b/main/src/test/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidatorTest.java @@ -0,0 +1,154 @@ +package org.mobilitydata.gtfsvalidator.validator; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mobilitydata.gtfsvalidator.notice.NoticeContainer; +import org.mobilitydata.gtfsvalidator.notice.ValidationNotice; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframe; +import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer; +import org.mobilitydata.gtfsvalidator.type.GtfsTime; +import org.mobilitydata.gtfsvalidator.validator.TimeframeOverlapValidator.TimeframeOverlapNoice; + +@RunWith(JUnit4.class) +public class TimeframeOverlapValidatorTest { + + @Test + public void testSingleTimeframe() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("00:00:00")) + .setEndTime(GtfsTime.fromString("24:00:00")) + .setCsvRowNumber(2) + .build())) + .isEmpty(); + } + + @Test + public void testNoOverlap() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(2) + .build(), + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("17:00:00")) + .setEndTime(GtfsTime.fromString("18:00:00")) + .setCsvRowNumber(3) + .build())) + .isEmpty(); + } + + @Test + public void testNoOverlapButAdjacent() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(2) + .build(), + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("09:00:00")) + .setEndTime(GtfsTime.fromString("10:00:00")) + .setCsvRowNumber(3) + .build())) + .isEmpty(); + } + + @Test + public void testOverlap() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(2) + .build(), + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:30:00")) + .setEndTime(GtfsTime.fromString("09:30:00")) + .setCsvRowNumber(3) + .build())) + .containsExactly( + new TimeframeOverlapNoice( + 2, + GtfsTime.fromString("09:00:00"), + 3, + GtfsTime.fromString("08:30:00"), + "PEAK", + "WEEKDAY")); + } + + @Test + public void testWithDifferentServiceIds() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(2) + .build(), + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKEND") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(3) + .build())) + .isEmpty(); + } + + @Test + public void testWithDifferentGroupIds() { + assertThat( + validate( + new GtfsTimeframe.Builder() + .setTimeframeGroupId("PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(2) + .build(), + new GtfsTimeframe.Builder() + .setTimeframeGroupId("NON-PEAK") + .setServiceId("WEEKDAY") + .setStartTime(GtfsTime.fromString("08:00:00")) + .setEndTime(GtfsTime.fromString("09:00:00")) + .setCsvRowNumber(3) + .build())) + .isEmpty(); + } + + private List validate(GtfsTimeframe... timeframes) { + NoticeContainer noticeContainer = new NoticeContainer(); + GtfsTimeframeTableContainer container = + GtfsTimeframeTableContainer.forEntities(Arrays.asList(timeframes), noticeContainer); + TimeframeOverlapValidator validator = new TimeframeOverlapValidator(container); + validator.validate(noticeContainer); + return noticeContainer.getValidationNotices(); + } +} From b179cc53ce805a6177327c608da3e36ba94fd348 Mon Sep 17 00:00:00 2001 From: Brian Ferris Date: Tue, 27 Jun 2023 15:34:49 -0700 Subject: [PATCH 6/6] Add validator comments. --- .../gtfsvalidator/validator/TimeframeOverlapValidator.java | 4 ++++ .../validator/TimeframeStartAndEndTimeValidator.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java index 78bf4ececd..ff0df11400 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeOverlapValidator.java @@ -20,6 +20,10 @@ import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeTableContainer; import org.mobilitydata.gtfsvalidator.type.GtfsTime; +/** + * Validates that two entries from `timesframes.txt` with the same `timeframe_group_id` and + * `service_id` do not have overlapping time intervals. + */ @GtfsValidator public class TimeframeOverlapValidator extends FileValidator { diff --git a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java index 6296a129fd..e0b2cbe3f4 100644 --- a/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java +++ b/main/src/main/java/org/mobilitydata/gtfsvalidator/validator/TimeframeStartAndEndTimeValidator.java @@ -27,6 +27,10 @@ import org.mobilitydata.gtfsvalidator.table.GtfsTimeframeSchema; import org.mobilitydata.gtfsvalidator.type.GtfsTime; +/** + * Validates the `start_time` and `end_time` values from `timeframes.txt`, checking that either both + * are present or neither. Also checks that no value is greater than 24-hours. + */ @GtfsValidator public class TimeframeStartAndEndTimeValidator extends SingleEntityValidator {