From 2b1cdc2636366670fe0ca7b95d60feafdc706aab Mon Sep 17 00:00:00 2001 From: Merlin Unterfinger Date: Sat, 4 May 2024 13:35:15 +0200 Subject: [PATCH] ENH: NAV-14 - Improve testing of GTFS schedule - Create larger example schedule with more calendar variations and both directions. - Increase test coverage concerning boundary cases in next departures (midnight), nearest stops (same location query) and active trips (dates outside validity or no service day). --- .../gtfs/schedule/type/ServiceDayTime.java | 4 + .../gtfs/schedule/model/GtfsScheduleTest.java | 154 ++++++++++++--- .../model/GtfsScheduleTestBuilder.java | 177 ++++++++++++++---- 3 files changed, 280 insertions(+), 55 deletions(-) diff --git a/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java b/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java index e6b29072..dc1238ec 100644 --- a/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java +++ b/src/main/java/ch/naviqore/gtfs/schedule/type/ServiceDayTime.java @@ -16,6 +16,10 @@ public final class ServiceDayTime implements Comparable { private final int totalSeconds; + public ServiceDayTime(int seconds) { + this.totalSeconds = seconds; + } + public ServiceDayTime(int hours, int minutes, int seconds) { this.totalSeconds = seconds + 60 * minutes + 3600 * hours; } diff --git a/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTest.java b/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTest.java index 2ab09724..573a1fba 100644 --- a/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTest.java +++ b/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTest.java @@ -1,11 +1,14 @@ package ch.naviqore.gtfs.schedule.model; +import ch.naviqore.gtfs.schedule.type.ServiceDayTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -17,8 +20,8 @@ class GtfsScheduleTest { @BeforeEach void setUp(GtfsScheduleTestBuilder builder) { - schedule = builder.withAddAgency().withAddCalendars().withAddStops().withAddRoutes().withAddTrips() - .withAddStopTimes().build(); + schedule = builder.withAddAgency().withAddCalendars().withAddCalendarDates().withAddInterCity() + .withAddUnderground().withAddBus().build(); } @Nested @@ -36,12 +39,12 @@ void shouldCorrectlyCountRoutes() { @Test void shouldCorrectlyCountStops() { - assertThat(schedule.getStops()).hasSize(5); + assertThat(schedule.getStops()).hasSize(9); } @Test void shouldCorrectlyCountTrips() { - assertThat(schedule.getTrips()).hasSize(3); + assertThat(schedule.getTrips()).hasSize(671); } @Test @@ -54,36 +57,122 @@ void shouldCorrectlyCountCalendars() { class NearestStops { @Test - void shouldFindStopsWithin500Meters() { - assertThat(schedule.getNearestStops(47.3769, 8.5417, 500)).hasSize(3).extracting("id") - .containsOnly("stop1", "stop2", "stop3"); + void shouldFindStopWithin1Meter() { + assertThat(schedule.getNearestStops(47.5, 8.5, 1)).hasSize(1).extracting("id").containsOnly("s2"); + } + + @Test + void shouldFindStopsWithin10000Meters() { + assertThat(schedule.getNearestStops(47.5, 8.5, 10000)).hasSize(3).extracting("id") + .containsOnly("u6", "s2", "u3"); + } + + @Test + void shouldFindAllStops() { + assertThat(schedule.getNearestStops(47.5, 8.5, Integer.MAX_VALUE)).hasSize(9).extracting("id") + .containsOnly("s1", "s2", "s3", "u1", "u2", "u3", "u4", "u5", "u6"); } @Test void shouldFindNoStopsWhenNoneAreCloseEnough() { - assertThat(schedule.getNearestStops(47.3800, 8.5500, 100)).isEmpty(); + assertThat(schedule.getNearestStops(47.6, 8.5, 100)).isEmpty(); } } @Nested class NextDepartures { + private static final String STOP_ID = "s2"; + private static final int LIMIT = 5; + + private static void assertWeekendAndHoliday(List departures) { + // assert departures times are correct + List expectedDepartures = List.of(ServiceDayTime.parse("08:15:00"), + ServiceDayTime.parse("08:15:00"), ServiceDayTime.parse("09:15:00"), + ServiceDayTime.parse("09:15:00"), ServiceDayTime.parse("10:15:00")); + assertThat(departures).hasSize(LIMIT).extracting(StopTime::departure) + .containsExactlyElementsOf(expectedDepartures); + + // assert trips are correct + List expectedTripIds = List.of("route1_we_f_4", "route1_we_r_4", "route1_we_f_5", "route1_we_r_5", + "route1_we_f_6"); + List tripIds = departures.stream().map(stopTime -> stopTime.trip().getId()).toList(); + assertThat(tripIds).containsExactlyElementsOf(expectedTripIds); + + // assert routes are correct + Set expectedRouteIds = Set.of("route1"); + List routeIds = departures.stream().map(stopTime -> stopTime.trip().getRoute().getId()).toList(); + assertThat(routeIds).allMatch(expectedRouteIds::contains); + } + @Test void shouldReturnNextDeparturesOnWeekday() { - assertThat(schedule.getNextDepartures("stop1", GtfsScheduleTestBuilder.Moments.WEEKDAY_8_AM, - Integer.MAX_VALUE)).hasSize(1); + List departures = schedule.getNextDepartures(STOP_ID, + GtfsScheduleTestBuilder.Moments.WEEKDAY_8_AM, LIMIT); + + // assert departures times are correct + List expectedDepartures = List.of(ServiceDayTime.parse("08:00:00"), + ServiceDayTime.parse("08:00:00"), ServiceDayTime.parse("08:09:00"), + ServiceDayTime.parse("08:09:00"), ServiceDayTime.parse("08:15:00")); + assertThat(departures).hasSize(LIMIT).extracting(StopTime::departure) + .containsExactlyElementsOf(expectedDepartures); + + // assert trips are correct + List expectedTripIds = List.of("route3_wd_f_16", "route3_wd_r_16", "route3_wd_f_17", + "route3_wd_r_17", "route1_wd_f_7"); + List tripIds = departures.stream().map(stopTime -> stopTime.trip().getId()).toList(); + assertThat(tripIds).containsExactlyElementsOf(expectedTripIds); + + // assert routes are correct + Set expectedRouteIds = Set.of("route1", "route3"); + List routeIds = departures.stream().map(stopTime -> stopTime.trip().getRoute().getId()).toList(); + assertThat(routeIds).allMatch(expectedRouteIds::contains); } @Test - void shouldReturnNoNextDeparturesOnWeekday() { - assertThat(schedule.getNextDepartures("stop1", GtfsScheduleTestBuilder.Moments.WEEKDAY_9_AM, - Integer.MAX_VALUE)).isEmpty(); + void shouldReturnNextDeparturesOnWeekend() { + List departures = schedule.getNextDepartures(STOP_ID, + GtfsScheduleTestBuilder.Moments.WEEKEND_8_AM, LIMIT); + + assertWeekendAndHoliday(departures); } @Test - void shouldReturnNextDeparturesOnSaturday() { - assertThat(schedule.getNextDepartures("stop1", GtfsScheduleTestBuilder.Moments.SATURDAY_9_AM, - Integer.MAX_VALUE)).hasSize(1); + void shouldReturnNextDeparturesOnHoliday() { + List departures = schedule.getNextDepartures(STOP_ID, + GtfsScheduleTestBuilder.Moments.HOLIDAY.atTime(8, 0), LIMIT); + + assertWeekendAndHoliday(departures); + } + + @Test + void shouldReturnNextDeparturesAfterMidnight() { + List departures = schedule.getNextDepartures(STOP_ID, + GtfsScheduleTestBuilder.Moments.WEEKDAY_12_PM, LIMIT); + + // assert departures times are correct + List expectedDepartures = List.of(ServiceDayTime.parse("24:00:00"), + ServiceDayTime.parse("24:00:00"), ServiceDayTime.parse("24:09:00"), + ServiceDayTime.parse("24:09:00"), ServiceDayTime.parse("24:15:00")); + assertThat(departures).hasSize(LIMIT).extracting(StopTime::departure) + .containsExactlyElementsOf(expectedDepartures); + + // assert trips are correct + List expectedTripIds = List.of("route3_wd_f_80", "route3_wd_r_80", "route3_wd_f_81", + "route3_wd_r_81", "route1_wd_f_39"); + List tripIds = departures.stream().map(stopTime -> stopTime.trip().getId()).toList(); + assertThat(tripIds).containsExactlyElementsOf(expectedTripIds); + + // assert routes are correct + Set expectedRouteIds = Set.of("route1", "route3"); + List routeIds = departures.stream().map(stopTime -> stopTime.trip().getRoute().getId()).toList(); + assertThat(routeIds).allMatch(expectedRouteIds::contains); + } + + @Test + void shouldReturnNoNextDeparturesOnNoServiceDay() { + assertThat(schedule.getNextDepartures(STOP_ID, GtfsScheduleTestBuilder.Moments.NO_SERVICE.atTime(8, 0), + Integer.MAX_VALUE)).isEmpty(); } @Test @@ -96,21 +185,44 @@ void shouldReturnNoDeparturesFromUnknownStop() { @Nested class ActiveTrips { + private static void assertWeekendAndHoliday(List activeTrips) { + assertThat(activeTrips).hasSize(168).extracting(trip -> trip.getRoute().getId()) + .containsAll(Set.of("route1", "route2")); + } + @Test void shouldReturnActiveTripsOnWeekday() { - assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Moments.WEEKDAY_8_AM.toLocalDate())).hasSize(2) - .extracting("id").containsOnly("trip1", "trip2"); + List activeTrips = schedule.getActiveTrips( + GtfsScheduleTestBuilder.Moments.WEEKDAY_8_AM.toLocalDate()); + + assertThat(activeTrips).hasSize(503).extracting(trip -> trip.getRoute().getId()) + .containsAll(Set.of("route1", "route2", "route3")); } @Test void shouldReturnActiveTripsOnWeekend() { - assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Moments.SATURDAY_9_AM.toLocalDate())).hasSize(1) - .extracting("id").containsOnly("trip3"); + List activeTrips = schedule.getActiveTrips( + GtfsScheduleTestBuilder.Moments.WEEKEND_8_AM.toLocalDate()); + + assertWeekendAndHoliday(activeTrips); + } + + @Test + void shouldReturnActiveTripsOnHoliday() { + List activeTrips = schedule.getActiveTrips(GtfsScheduleTestBuilder.Moments.HOLIDAY); + + assertWeekendAndHoliday(activeTrips); + } + + @Test + void shouldReturnNoActiveTripsForDaysOutsideValidity() { + assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Validity.PERIOD_START.minusDays(1))).isEmpty(); + assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Validity.PERIOD_END.plusDays(1))).isEmpty(); } @Test void shouldReturnNoActiveTripsForNonServiceDay() { - assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Validity.END_DATE.plusMonths(1))).isEmpty(); + assertThat(schedule.getActiveTrips(GtfsScheduleTestBuilder.Moments.NO_SERVICE)).isEmpty(); } } } diff --git a/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTestBuilder.java b/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTestBuilder.java index cb998ce8..8c77bec1 100644 --- a/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTestBuilder.java +++ b/src/test/java/ch/naviqore/gtfs/schedule/model/GtfsScheduleTestBuilder.java @@ -1,7 +1,9 @@ package ch.naviqore.gtfs.schedule.model; import ch.naviqore.gtfs.schedule.type.DefaultRouteType; +import ch.naviqore.gtfs.schedule.type.ExceptionType; import ch.naviqore.gtfs.schedule.type.HierarchicalVehicleType; +import ch.naviqore.gtfs.schedule.type.RouteType; import ch.naviqore.gtfs.schedule.type.ServiceDayTime; import lombok.NoArgsConstructor; @@ -9,27 +11,70 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Month; +import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; /** * Test builder to set up a GTFS schedule for testing purposes. + *

+ * Simple example schedule: + *

+ *            u1 ------------------ u2
+ *            |                   / |
+ *            |                /    |
+ *            |             /       |
+ * s1 ------- u6 ------- s2 ------- u3 ------- s3
+ *            |        /            |
+ *            |     /               |
+ *            |  /                  |
+ *            u5 ------------------ u4
+ * 
+ * Routes: + *
    + *
  • route1 - Everyday service, InterCity train from Other City to Different City via Main Station (s1, s2, s3)
  • + *
  • route2 - Everyday service, Underground system covering six stops (u1, u2, u3, u4, u5, u6)
  • + *
  • route3 - Weekday service, Bus between South-West, Main Station, and North-East (u5, s2, u2)
  • + *
+ * Stations: + *
    + *
  • s1 - Other City (47.5, 7.5)
  • + *
  • s2 - Main Station (47.5, 8.5)
  • + *
  • s3 - Different City (47.5, 9.5)
  • + *
+ * Underground: + *
    + *
  • u1 - North-West (47.6, 8.4)
  • + *
  • u2 - North-East (47.6, 8.6)
  • + *
  • u3 - East (47.5, 8.6)
  • + *
  • u4 - South-East (47.4, 8.6)
  • + *
  • u5 - South-West (47.4, 8.4)
  • + *
  • u6 - West (47.5, 8.4)
  • + *
* * @author munterfi */ @NoArgsConstructor public class GtfsScheduleTestBuilder { - public static final class Validity { - public static final LocalDate START_DATE = LocalDate.of(2024, Month.APRIL, 1); - public static final LocalDate END_DATE = START_DATE.plusMonths(1); - } - - public static final class Moments { - public static final LocalDateTime WEEKDAY_8_AM = LocalDateTime.of(2024, Month.APRIL, 26, 8, 0); - public static final LocalDateTime WEEKDAY_9_AM = WEEKDAY_8_AM.plusHours(1); - public static final LocalDateTime SATURDAY_9_AM = LocalDateTime.of(2024, Month.APRIL, 27, 9, 0); - } - + private static final int NO_HEADWAY = -1; + private static final Map STOPS = Map.of("s1", new Stop("s1", "Other City", 47.5, 7.5), "s2", + new Stop("s2", "Main Station", 47.5, 8.5), "s3", new Stop("s3", "Different City", 47.5, 9.5), "u1", + new Stop("u1", "North-West", 47.6, 8.4), "u2", new Stop("u2", "North-East", 47.6, 8.6), "u3", + new Stop("u3", "East", 47.5, 8.6), "u4", new Stop("u4", "South-East", 47.4, 8.6), "u5", + new Stop("u5", "South-West", 47.4, 8.4), "u6", new Stop("u6", "West", 47.5, 8.4)); + private static final List ROUTES = List.of( + new Route("route1", "agency1", "IC", HierarchicalVehicleType.LONG_DISTANCE_TRAINS, 30, 60, 10, 60, 5, + List.of("s1", "s2", "s3")), + new Route("route2", "agency2", "UNDERGROUND", HierarchicalVehicleType.SUBURBAN_RAILWAY, 5, 10, 10, 3, 1, + List.of("u1", "u2", "u3", "u4", "u5", "u6")), + new Route("route3", "agency2", "BUS", DefaultRouteType.BUS, 15, NO_HEADWAY, 3, 5, 1, + List.of("u5", "s2", "s2"))); + private final Set addedStops = new HashSet<>(); private final GtfsScheduleBuilder builder = GtfsSchedule.builder(); public GtfsScheduleTestBuilder withAddAgency() { @@ -39,39 +84,35 @@ public GtfsScheduleTestBuilder withAddAgency() { } public GtfsScheduleTestBuilder withAddCalendars() { - builder.addCalendar("weekdays", EnumSet.range(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), Validity.START_DATE, - Validity.END_DATE) - .addCalendar("weekends", EnumSet.of(DayOfWeek.SATURDAY), Validity.START_DATE, Validity.END_DATE); + builder.addCalendar("weekdays", EnumSet.range(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), Validity.PERIOD_START, + Validity.PERIOD_END) + .addCalendar("weekends", EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY), Validity.PERIOD_START, + Validity.PERIOD_END); return this; } - public GtfsScheduleTestBuilder withAddStops() { - builder.addStop("stop1", "Main Station", 47.3769, 8.5417).addStop("stop2", "Central Park", 47.3779, 8.5407) - .addStop("stop3", "Hill Valley", 47.3780, 8.5390).addStop("stop4", "East Side", 47.3785, 8.5350) - .addStop("stop5", "West End", 47.3750, 8.5300); + public GtfsScheduleTestBuilder withAddCalendarDates() { + // change service to sunday + builder.addCalendarDate("weekdays", Moments.HOLIDAY, ExceptionType.REMOVED); + builder.addCalendarDate("weekends", Moments.HOLIDAY, ExceptionType.ADDED); + // no service + builder.addCalendarDate("weekdays", Moments.NO_SERVICE, ExceptionType.REMOVED); return this; } - public GtfsScheduleTestBuilder withAddRoutes() { - builder.addRoute("route1", "agency1", "101", "Main Line", DefaultRouteType.RAIL) - .addRoute("route2", "agency2", "102", "Cross Town", DefaultRouteType.BUS) - .addRoute("route3", "agency2", "103", "Circulator", HierarchicalVehicleType.SUBURBAN_RAILWAY); + public GtfsScheduleTestBuilder withAddInterCity() { + addRoute(ROUTES.getFirst(), true, true); return this; } - public GtfsScheduleTestBuilder withAddTrips() { - builder.addTrip("trip1", "route1", "weekdays").addTrip("trip2", "route2", "weekdays") - .addTrip("trip3", "route3", "weekends"); + + public GtfsScheduleTestBuilder withAddUnderground() { + addRoute(ROUTES.get(1), true, false); return this; } - public GtfsScheduleTestBuilder withAddStopTimes() { - builder.addStopTime("trip1", "stop1", hms("08:00:00"), hms("08:05:00")) - .addStopTime("trip1", "stop2", hms("08:10:00"), hms("08:15:00")) - .addStopTime("trip2", "stop3", hms("09:00:00"), hms("09:05:00")) - .addStopTime("trip2", "stop4", hms("09:10:00"), hms("09:15:00")) - .addStopTime("trip3", "stop5", hms("10:00:00"), hms("10:05:00")) - .addStopTime("trip3", "stop1", hms("10:10:00"), hms("10:15:00")); + public GtfsScheduleTestBuilder withAddBus() { + addRoute(ROUTES.get(2), false, true); return this; } @@ -79,7 +120,75 @@ public GtfsSchedule build() { return builder.build(); } - private static ServiceDayTime hms(String time) { - return ServiceDayTime.parse(time); + + private void addRoute(Route route, boolean everydayService, boolean bidirectional) { + builder.addRoute(route.id, route.agencyId, route.name, route.name + "long", route.routeType); + addStops(route); + addTrips(route, true, false); + if (everydayService) { + addTrips(route, false, false); + } + if (bidirectional) { + addTrips(route, true, true); + if (everydayService) { + addTrips(route, false, true); + } + } + } + + private void addTrips(Route route, boolean weekday, boolean reverse) { + final int travelTime = route.travelTime * 60; + final int dwellTime = route.dwellTime * 60; + final int headway = weekday ? route.headwayWeekday * 60 : route.headwayWeekend * 60; + final List routeStops = new ArrayList<>(route.stops); + String weekdayPostfix = weekday ? "wd" : "we"; + String directionPostfix = "f"; + if (reverse) { + Collections.reverse(routeStops); + directionPostfix = "r"; + } + int tripCount = 0; + for (int tripDepartureTime = Validity.SERVICE_DAY_START.getTotalSeconds() + route.offset * 60; tripDepartureTime <= Validity.SERVICE_DAY_END.getTotalSeconds(); tripDepartureTime += headway) { + String tripId = String.format("%s_%s_%s_%s", route.id, weekdayPostfix, directionPostfix, ++tripCount); + builder.addTrip(tripId, route.id, weekday ? "weekdays" : "weekends"); + int departureTime = tripDepartureTime; + for (String stopId : route.stops) { + builder.addStopTime(tripId, stopId, new ServiceDayTime(departureTime - dwellTime), + new ServiceDayTime(departureTime)); + departureTime += travelTime + dwellTime; + } + } + } + + private void addStops(Route route) { + for (String stopId : route.stops) { + if (!addedStops.contains(stopId)) { + Stop stop = STOPS.get(stopId); + builder.addStop(stop.id, stop.id, stop.lat, stop.lon); + addedStops.add(stopId); + } + } + } + + record Stop(String id, String name, double lat, double lon) { + } + + record Route(String id, String agencyId, String name, RouteType routeType, int headwayWeekday, int headwayWeekend, + int offset, int travelTime, int dwellTime, List stops) { + } + + public static final class Validity { + public static final ServiceDayTime SERVICE_DAY_START = new ServiceDayTime(4, 0, 0); + public static final ServiceDayTime SERVICE_DAY_END = new ServiceDayTime(25, 0, 0); + public static final LocalDate PERIOD_START = LocalDate.of(2024, Month.JANUARY, 1); + public static final LocalDate PERIOD_END = LocalDate.of(2024, Month.DECEMBER, 31); + } + + public static final class Moments { + public static final LocalDateTime WEEKDAY_8_AM = LocalDateTime.of(2024, Month.APRIL, 26, 8, 0); + public static final LocalDateTime WEEKDAY_12_PM = LocalDateTime.of(2024, Month.APRIL, 26, 23, 59); + public static final LocalDateTime WEEKEND_8_AM = LocalDateTime.of(2024, Month.APRIL, 27, 8, 0); + public static final LocalDate NO_SERVICE = LocalDate.of(2024, Month.MAY, 1); + public static final LocalDate HOLIDAY = LocalDate.of(2024, Month.DECEMBER, 25); } }