{
@Nonnull
public String serialize(@Nonnull Object input) throws CoercingSerializeException {
if (input instanceof Duration duration) {
- return duration.toString();
+ return DurationUtils.formatDurationWithLeadingMinus(duration);
}
throw new CoercingSerializeException(input + " cannot be cast to 'Duration'");
diff --git a/src/main/java/org/opentripplanner/framework/io/OtpHttpClient.java b/src/main/java/org/opentripplanner/framework/io/OtpHttpClient.java
index 72b67441a18..c922cfcc4be 100644
--- a/src/main/java/org/opentripplanner/framework/io/OtpHttpClient.java
+++ b/src/main/java/org/opentripplanner/framework/io/OtpHttpClient.java
@@ -138,6 +138,7 @@ private OtpHttpClient(Duration timeout, Duration connectionTtl, int maxConnectio
HttpClientBuilder httpClientBuilder = HttpClients
.custom()
+ .setUserAgent("OpenTripPlanner")
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig(timeout));
diff --git a/src/main/java/org/opentripplanner/framework/lang/StringUtils.java b/src/main/java/org/opentripplanner/framework/lang/StringUtils.java
index cbb32b9eaca..9eff448d0c7 100644
--- a/src/main/java/org/opentripplanner/framework/lang/StringUtils.java
+++ b/src/main/java/org/opentripplanner/framework/lang/StringUtils.java
@@ -1,5 +1,6 @@
package org.opentripplanner.framework.lang;
+import java.util.regex.Pattern;
import javax.annotation.Nonnull;
/**
@@ -7,6 +8,18 @@
*/
public class StringUtils {
+ /**
+ * Regex to find unprintable characters like newlines and 'ZERO WIDTH SPACE' (U+200B).
+ *
+ * \p{C} was chosen over \p{Cntrl} because it also recognises invisible control characters in the
+ * middle of a word.
+ */
+ private static final String INVISIBLE_CHARS_REGEX = "\\p{C}";
+ /**
+ * Patterns are immutable and thread safe.
+ */
+ private static final Pattern INVISIBLE_CHARS_PATTERN = Pattern.compile(INVISIBLE_CHARS_REGEX);
+
private StringUtils() {}
/** true if the given text is not {@code null} or has at least one none white-space character. */
@@ -119,4 +132,14 @@ public static String quoteReplace(@Nonnull String text) {
public static String kebabCase(String input) {
return input.toLowerCase().replace('_', '-');
}
+
+ /**
+ * Detects unprintable control characters like newlines, tabs and invisible whitespace
+ * like 'ZERO WIDTH SPACE' (U+200B) that don't have an immediate visual representation.
+ *
+ * Note that "regular" whitespace characters like U+0020 and U+2000 are considered visible.
+ */
+ public static boolean containsInvisibleCharacters(String input) {
+ return INVISIBLE_CHARS_PATTERN.matcher(input).find();
+ }
}
diff --git a/src/main/java/org/opentripplanner/framework/time/DurationUtils.java b/src/main/java/org/opentripplanner/framework/time/DurationUtils.java
index bf59964fbb2..9a01d308a1c 100644
--- a/src/main/java/org/opentripplanner/framework/time/DurationUtils.java
+++ b/src/main/java/org/opentripplanner/framework/time/DurationUtils.java
@@ -196,4 +196,22 @@ public static int toIntMilliseconds(Duration timeout, int defaultValue) {
private static String msToSecondsStr(String formatSeconds, double timeMs) {
return String.format(ROOT, formatSeconds, timeMs / 1000.0) + " seconds";
}
+
+ /**
+ * Formats a duration and if it's a negative amount, it places the minus before the "P" rather
+ * than in the middle of the value.
+ *
+ * Background: There are multiple ways to express -1.5 hours: "PT-1H-30M" and "-PT1H30M".
+ *
+ * The first version is what you get when calling toString() but it's quite confusing. Therefore,
+ * this method makes sure that you get the second form "-PT1H30M".
+ */
+ public static String formatDurationWithLeadingMinus(Duration duration) {
+ if (duration.isNegative()) {
+ var positive = duration.abs().toString();
+ return "-" + positive;
+ } else {
+ return duration.toString();
+ }
+ }
}
diff --git a/src/main/java/org/opentripplanner/framework/time/OffsetDateTimeParser.java b/src/main/java/org/opentripplanner/framework/time/OffsetDateTimeParser.java
new file mode 100644
index 00000000000..8b40697977a
--- /dev/null
+++ b/src/main/java/org/opentripplanner/framework/time/OffsetDateTimeParser.java
@@ -0,0 +1,42 @@
+package org.opentripplanner.framework.time;
+
+import java.text.ParseException;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+
+public class OffsetDateTimeParser {
+
+ /**
+ * We need to have two offsets, in order to parse both "+0200" and "+02:00". The first is not
+ * really ISO-8601 compatible with the extended date and time. We need to make parsing strict, in
+ * order to keep the minute mandatory, otherwise we would be left with an unparsed minute
+ */
+ public static final DateTimeFormatter LENIENT_PARSER = new DateTimeFormatterBuilder()
+ .parseCaseInsensitive()
+ .parseLenient()
+ .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+ .optionalStart()
+ .parseStrict()
+ .appendOffset("+HH:MM:ss", "Z")
+ .parseLenient()
+ .optionalEnd()
+ .optionalStart()
+ .appendOffset("+HHmmss", "Z")
+ .optionalEnd()
+ .toFormatter();
+
+ /**
+ * Parses a ISO-8601 string into am OffsetDateTime instance allowing the offset to be both in
+ * '02:00' and '0200' format.
+ * @throws ParseException if the string cannot be parsed
+ */
+ public static OffsetDateTime parseLeniently(CharSequence input) throws ParseException {
+ try {
+ return OffsetDateTime.parse(input, LENIENT_PARSER);
+ } catch (DateTimeParseException e) {
+ throw new ParseException(e.getParsedString(), e.getErrorIndex());
+ }
+ }
+}
diff --git a/src/main/java/org/opentripplanner/gtfs/graphbuilder/GtfsModule.java b/src/main/java/org/opentripplanner/gtfs/graphbuilder/GtfsModule.java
index cf9d48c64c5..2f7feb5993e 100644
--- a/src/main/java/org/opentripplanner/gtfs/graphbuilder/GtfsModule.java
+++ b/src/main/java/org/opentripplanner/gtfs/graphbuilder/GtfsModule.java
@@ -120,12 +120,20 @@ public void buildGraph() {
boolean hasTransit = false;
+ Map feedIdsEncountered = new HashMap<>();
+
try {
for (GtfsBundle gtfsBundle : gtfsBundles) {
GtfsMutableRelationalDao gtfsDao = loadBundle(gtfsBundle);
+
+ final String feedId = gtfsBundle.getFeedId().getId();
+ verifyUniqueFeedId(gtfsBundle, feedIdsEncountered, feedId);
+
+ feedIdsEncountered.put(feedId, gtfsBundle);
+
GTFSToOtpTransitServiceMapper mapper = new GTFSToOtpTransitServiceMapper(
new OtpTransitServiceBuilder(transitModel.getStopModel(), issueStore),
- gtfsBundle.getFeedId().getId(),
+ feedId,
issueStore,
gtfsBundle.discardMinTransferTimes(),
gtfsDao,
@@ -203,6 +211,32 @@ public void buildGraph() {
transitModel.updateCalendarServiceData(hasTransit, calendarServiceData, issueStore);
}
+ /**
+ * Verifies that a feed id is not assigned twice.
+ *
+ * Duplicates can happen in the following cases:
+ * - the feed id is configured twice in build-config.json
+ * - two GTFS feeds have the same feed_info.feed_id
+ * - a GTFS feed defines a feed_info.feed_id like '3' that collides with an auto-generated one
+ *
+ * Debugging these cases is very confusing, so we prevent it from happening.
+ */
+ private static void verifyUniqueFeedId(
+ GtfsBundle gtfsBundle,
+ Map feedIdsEncountered,
+ String feedId
+ ) {
+ if (feedIdsEncountered.containsKey(feedId)) {
+ LOG.error(
+ "Feed id '{}' has been used for {} but it was already assigned to {}.",
+ feedId,
+ gtfsBundle,
+ feedIdsEncountered.get(feedId)
+ );
+ throw new IllegalArgumentException("Duplicate feed id: '%s'".formatted(feedId));
+ }
+ }
+
@Override
public void checkInputs() {
for (GtfsBundle bundle : gtfsBundles) {
diff --git a/src/main/java/org/opentripplanner/model/plan/FrequencyTransitLeg.java b/src/main/java/org/opentripplanner/model/plan/FrequencyTransitLeg.java
index d2188fb1b0b..48c4b1b7b87 100644
--- a/src/main/java/org/opentripplanner/model/plan/FrequencyTransitLeg.java
+++ b/src/main/java/org/opentripplanner/model/plan/FrequencyTransitLeg.java
@@ -1,16 +1,9 @@
package org.opentripplanner.model.plan;
-import java.time.LocalDate;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
-import javax.annotation.Nullable;
import org.opentripplanner.framework.time.ServiceDateUtils;
-import org.opentripplanner.model.transfer.ConstrainedTransfer;
-import org.opentripplanner.transit.model.network.TripPattern;
import org.opentripplanner.transit.model.site.StopLocation;
-import org.opentripplanner.transit.model.timetable.TripTimes;
/**
* One leg of a trip -- that is, a temporally continuous piece of the journey that takes place on a
@@ -62,8 +55,8 @@ public List getIntermediateStops() {
StopArrival visit = new StopArrival(
Place.forStop(stop),
- ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, arrivalTime),
- ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, departureTime),
+ LegTime.ofStatic(ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, arrivalTime)),
+ LegTime.ofStatic(ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, departureTime)),
i,
tripTimes.gtfsSequenceOfStopIndex(i)
);
diff --git a/src/main/java/org/opentripplanner/model/plan/Leg.java b/src/main/java/org/opentripplanner/model/plan/Leg.java
index 05213dbdf5e..5a032def979 100644
--- a/src/main/java/org/opentripplanner/model/plan/Leg.java
+++ b/src/main/java/org/opentripplanner/model/plan/Leg.java
@@ -200,6 +200,16 @@ default Accessibility getTripWheelchairAccessibility() {
return null;
}
+ /**
+ * The time (including realtime information) when the leg starts.
+ */
+ LegTime start();
+
+ /**
+ * The time (including realtime information) when the leg ends.
+ */
+ LegTime end();
+
/**
* The date and time this leg begins.
*/
diff --git a/src/main/java/org/opentripplanner/model/plan/LegTime.java b/src/main/java/org/opentripplanner/model/plan/LegTime.java
new file mode 100644
index 00000000000..354ce4f4b0b
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/plan/LegTime.java
@@ -0,0 +1,45 @@
+package org.opentripplanner.model.plan;
+
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * A scheduled time of a transit vehicle at a certain location with a optional realtime information.
+ */
+public record LegTime(@Nonnull ZonedDateTime scheduledTime, @Nullable RealTimeEstimate estimated) {
+ public LegTime {
+ Objects.requireNonNull(scheduledTime);
+ }
+
+ @Nonnull
+ public static LegTime of(ZonedDateTime realtime, int delaySecs) {
+ var delay = Duration.ofSeconds(delaySecs);
+ return new LegTime(realtime.minus(delay), new RealTimeEstimate(realtime, delay));
+ }
+
+ @Nonnull
+ public static LegTime ofStatic(ZonedDateTime staticTime) {
+ return new LegTime(staticTime, null);
+ }
+
+ /**
+ * The most up-to-date time available: if realtime data is available it is returned, if not then
+ * the scheduled one is.
+ */
+ public ZonedDateTime time() {
+ if (estimated == null) {
+ return scheduledTime;
+ } else {
+ return estimated.time;
+ }
+ }
+
+ /**
+ * Realtime information about a vehicle at a certain place.
+ * @param delay Delay or "earliness" of a vehicle. Earliness is expressed as a negative number.
+ */
+ record RealTimeEstimate(ZonedDateTime time, Duration delay) {}
+}
diff --git a/src/main/java/org/opentripplanner/model/plan/ScheduledTransitLeg.java b/src/main/java/org/opentripplanner/model/plan/ScheduledTransitLeg.java
index 876567035dd..6bf39d5aa4c 100644
--- a/src/main/java/org/opentripplanner/model/plan/ScheduledTransitLeg.java
+++ b/src/main/java/org/opentripplanner/model/plan/ScheduledTransitLeg.java
@@ -162,6 +162,24 @@ public Accessibility getTripWheelchairAccessibility() {
return tripTimes.getWheelchairAccessibility();
}
+ @Override
+ public LegTime start() {
+ if (getRealTime()) {
+ return LegTime.of(startTime, getDepartureDelay());
+ } else {
+ return LegTime.ofStatic(startTime);
+ }
+ }
+
+ @Override
+ public LegTime end() {
+ if (getRealTime()) {
+ return LegTime.of(endTime, getArrivalDelay());
+ } else {
+ return LegTime.ofStatic(endTime);
+ }
+ }
+
@Override
@Nonnull
public TransitMode getMode() {
@@ -257,17 +275,11 @@ public Place getTo() {
@Override
public List getIntermediateStops() {
List visits = new ArrayList<>();
+ var mapper = new StopArrivalMapper(zoneId, serviceDate, tripTimes);
for (int i = boardStopPosInPattern + 1; i < alightStopPosInPattern; i++) {
StopLocation stop = tripPattern.getStop(i);
-
- StopArrival visit = new StopArrival(
- Place.forStop(stop),
- ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, tripTimes.getArrivalTime(i)),
- ServiceDateUtils.toZonedDateTime(serviceDate, zoneId, tripTimes.getDepartureTime(i)),
- i,
- tripTimes.gtfsSequenceOfStopIndex(i)
- );
+ final StopArrival visit = mapper.map(i, stop, getRealTime());
visits.add(visit);
}
return visits;
diff --git a/src/main/java/org/opentripplanner/model/plan/StopArrival.java b/src/main/java/org/opentripplanner/model/plan/StopArrival.java
index 10398768c85..489b122da09 100644
--- a/src/main/java/org/opentripplanner/model/plan/StopArrival.java
+++ b/src/main/java/org/opentripplanner/model/plan/StopArrival.java
@@ -1,39 +1,31 @@
package org.opentripplanner.model.plan;
-import java.time.ZonedDateTime;
import org.opentripplanner.framework.tostring.ToStringBuilder;
/**
* This class is used to represent a stop arrival event mostly for intermediate visits to a stops
* along a route.
*/
-public class StopArrival {
+public final class StopArrival {
public final Place place;
- /**
- * The time the rider will arrive at the place.
- */
- public final ZonedDateTime arrival;
-
- /**
- * The time the rider will depart the place.
- */
- public final ZonedDateTime departure;
-
- /**
- * For transit trips, the stop index (numbered from zero from the start of the trip).
- */
+ public final LegTime arrival;
+ public final LegTime departure;
public final Integer stopPosInPattern;
+ public final Integer gtfsStopSequence;
/**
- * For transit trips, the sequence number of the stop. Per GTFS, these numbers are increasing.
+ * @param arrival The time the rider will arrive at the place.
+ * @param departure The time the rider will depart the place.
+ * @param stopPosInPattern For transit trips, the stop index (numbered from zero from the start of
+ * the trip).
+ * @param gtfsStopSequence For transit trips, the sequence number of the stop. Per GTFS, these
+ * numbers are increasing.
*/
- public final Integer gtfsStopSequence;
-
public StopArrival(
Place place,
- ZonedDateTime arrival,
- ZonedDateTime departure,
+ LegTime arrival,
+ LegTime departure,
Integer stopPosInPattern,
Integer gtfsStopSequence
) {
@@ -48,8 +40,8 @@ public StopArrival(
public String toString() {
return ToStringBuilder
.of(StopArrival.class)
- .addTime("arrival", arrival)
- .addTime("departure", departure)
+ .addObj("arrival", arrival)
+ .addObj("departure", departure)
.addObj("place", place)
.toString();
}
diff --git a/src/main/java/org/opentripplanner/model/plan/StopArrivalMapper.java b/src/main/java/org/opentripplanner/model/plan/StopArrivalMapper.java
new file mode 100644
index 00000000000..25bab2a7c3f
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/plan/StopArrivalMapper.java
@@ -0,0 +1,53 @@
+package org.opentripplanner.model.plan;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Objects;
+import org.opentripplanner.framework.time.ServiceDateUtils;
+import org.opentripplanner.transit.model.site.StopLocation;
+import org.opentripplanner.transit.model.timetable.TripTimes;
+
+/**
+ * Maps leg-related information to an instance of {@link StopArrival}.
+ */
+class StopArrivalMapper {
+
+ private final ZoneId zoneId;
+ private final LocalDate serviceDate;
+ private final TripTimes tripTimes;
+
+ public StopArrivalMapper(ZoneId zoneId, LocalDate serviceDate, TripTimes tripTimes) {
+ this.zoneId = Objects.requireNonNull(zoneId);
+ this.serviceDate = Objects.requireNonNull(serviceDate);
+ this.tripTimes = Objects.requireNonNull(tripTimes);
+ }
+
+ StopArrival map(int i, StopLocation stop, boolean realTime) {
+ final var arrivalTime = ServiceDateUtils.toZonedDateTime(
+ serviceDate,
+ zoneId,
+ tripTimes.getArrivalTime(i)
+ );
+ final var departureTime = ServiceDateUtils.toZonedDateTime(
+ serviceDate,
+ zoneId,
+ tripTimes.getDepartureTime(i)
+ );
+
+ var arrival = LegTime.ofStatic(arrivalTime);
+ var departure = LegTime.ofStatic(departureTime);
+
+ if (realTime) {
+ arrival = LegTime.of(arrivalTime, tripTimes.getArrivalDelay(i));
+ departure = LegTime.of(departureTime, tripTimes.getDepartureDelay(i));
+ }
+
+ return new StopArrival(
+ Place.forStop(stop),
+ arrival,
+ departure,
+ i,
+ tripTimes.gtfsSequenceOfStopIndex(i)
+ );
+ }
+}
diff --git a/src/main/java/org/opentripplanner/model/plan/StreetLeg.java b/src/main/java/org/opentripplanner/model/plan/StreetLeg.java
index 319c9e01f69..6384159a4ec 100644
--- a/src/main/java/org/opentripplanner/model/plan/StreetLeg.java
+++ b/src/main/java/org/opentripplanner/model/plan/StreetLeg.java
@@ -156,6 +156,16 @@ public boolean hasSameMode(Leg other) {
return other instanceof StreetLeg oSL && mode.equals(oSL.mode);
}
+ @Override
+ public LegTime start() {
+ return LegTime.ofStatic(startTime);
+ }
+
+ @Override
+ public LegTime end() {
+ return LegTime.ofStatic(endTime);
+ }
+
@Override
public Leg withTimeShift(Duration duration) {
return StreetLegBuilder
diff --git a/src/main/java/org/opentripplanner/model/plan/UnknownTransitPathLeg.java b/src/main/java/org/opentripplanner/model/plan/UnknownTransitPathLeg.java
index 382a09d34e2..df43bbcb411 100644
--- a/src/main/java/org/opentripplanner/model/plan/UnknownTransitPathLeg.java
+++ b/src/main/java/org/opentripplanner/model/plan/UnknownTransitPathLeg.java
@@ -68,6 +68,16 @@ public boolean hasSameMode(Leg other) {
return false;
}
+ @Override
+ public LegTime start() {
+ return LegTime.ofStatic(startTime);
+ }
+
+ @Override
+ public LegTime end() {
+ return LegTime.ofStatic(endTime);
+ }
+
@Override
public double getDistanceMeters() {
return UNKNOWN;
diff --git a/src/main/java/org/opentripplanner/raptor/rangeraptor/transit/AccessPaths.java b/src/main/java/org/opentripplanner/raptor/rangeraptor/transit/AccessPaths.java
index d070a804f9c..66b7227584d 100644
--- a/src/main/java/org/opentripplanner/raptor/rangeraptor/transit/AccessPaths.java
+++ b/src/main/java/org/opentripplanner/raptor/rangeraptor/transit/AccessPaths.java
@@ -194,7 +194,7 @@ private List filterOnTimePenaltyLimitIfExist(List e.timePenalty() > iterationTimePenaltyLimit).toList();
}
return list;
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/mapping/AlertToLegMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/mapping/AlertToLegMapper.java
index dd08ab6095d..94630be7600 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/mapping/AlertToLegMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/mapping/AlertToLegMapper.java
@@ -91,8 +91,8 @@ public void addTransitAlertsToLeg(Leg leg, boolean isFirstLeg) {
)
);
- ZonedDateTime stopArrival = visit.arrival;
- ZonedDateTime stopDeparture = visit.departure;
+ ZonedDateTime stopArrival = visit.arrival.scheduledTime();
+ ZonedDateTime stopDeparture = visit.departure.scheduledTime();
addTransitAlertsToLeg(leg, alerts, stopArrival, stopDeparture);
}
diff --git a/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelope.java b/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelope.java
index 8d211fd33db..27a1fb4d30f 100644
--- a/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelope.java
+++ b/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelope.java
@@ -6,8 +6,7 @@
import org.opentripplanner.framework.tostring.ToStringBuilder;
/**
- * This class calculates borders of envelopes that can be also on 180th meridian The same way as it
- * was previously calculated in GraphMetadata constructor
+ * This class calculates borders of envelopes that can be also on 180th meridian.
*/
public class WorldEnvelope implements Serializable {
@@ -53,14 +52,6 @@ public WgsCoordinate upperRight() {
return upperRight;
}
- /**
- * This is the center of the Envelope including both street vertexes and transit stops
- * if they exist.
- */
- public WgsCoordinate meanCenter() {
- return meanCenter;
- }
-
/**
* If transit data exist, then this is the median center of the transit stops. The median
* is computed independently for the longitude and latitude.
@@ -68,13 +59,21 @@ public WgsCoordinate meanCenter() {
* If not transit data exist this return `empty`.
*/
public WgsCoordinate center() {
- return transitMedianCenter().orElse(meanCenter);
+ return medianCenter().orElse(meanCenter);
+ }
+
+ /**
+ * This is the center of the Envelope including both street vertexes and transit stops
+ * if they exist.
+ */
+ public WgsCoordinate meanCenter() {
+ return meanCenter;
}
/**
* Return the transit median center [if it exist] or the mean center.
*/
- public Optional transitMedianCenter() {
+ public Optional medianCenter() {
return Optional.ofNullable(transitMedianCenter);
}
diff --git a/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelopeBuilder.java b/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelopeBuilder.java
index abddba9c5fd..7c5bf13d5e5 100644
--- a/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelopeBuilder.java
+++ b/src/main/java/org/opentripplanner/service/worldenvelope/model/WorldEnvelopeBuilder.java
@@ -58,8 +58,23 @@ public WorldEnvelopeBuilder expandToIncludeTransitEntities(
var medianCalculator = new MedianCalcForDoubles(collection.size());
- collection.forEach(v -> medianCalculator.add(lonProvider.apply(v)));
- double lon = medianCalculator.median();
+ double lon = 0.0;
+ if (includeLongitude180()) {
+ collection.forEach(v -> {
+ double c = lonProvider.apply(v);
+ if (c < 0) {
+ c += 360.0;
+ }
+ medianCalculator.add(c);
+ });
+ lon = medianCalculator.median();
+ if (lon > 180.0) {
+ lon -= 180;
+ }
+ } else {
+ collection.forEach(v -> medianCalculator.add(lonProvider.apply(v)));
+ lon = medianCalculator.median();
+ }
medianCalculator.reset();
collection.forEach(v -> medianCalculator.add(latProvider.apply(v)));
@@ -79,19 +94,26 @@ public WorldEnvelope build() {
if (minLonEast == MIN_NOT_SET) {
return new WorldEnvelope(minLat, minLonWest, maxLat, maxLonWest, transitMedianCenter);
}
- // Envelope intersects with either 0º or 180º
- double dist0 = minLonEast - minLonWest;
- double dist180 = 360d - maxLonEast + minLonWest;
-
- // A small gap between the east and west longitude at 0 degrees implies that the Envelope
- // should include the 0 degrees longitude(meridian), and be split at 180 degrees.
- if (dist0 < dist180) {
- return new WorldEnvelope(minLat, maxLonWest, maxLat, maxLonEast, transitMedianCenter);
- } else {
+ if (includeLongitude180()) {
return new WorldEnvelope(minLat, minLonEast, maxLat, minLonWest, transitMedianCenter);
+ } else {
+ return new WorldEnvelope(minLat, minLonWest, maxLat, maxLonEast, transitMedianCenter);
}
}
+ /**
+ * A small gap between the east and west longitude at 180º degrees implies that the Envelope
+ * should include the 180º longitude, and be split at 0 degrees.
+ */
+ boolean includeLongitude180() {
+ if (minLonWest == MIN_NOT_SET || minLonEast == MIN_NOT_SET) {
+ return false;
+ }
+ double dist0 = minLonEast - minLonWest;
+ double dist180 = 360d - maxLonEast + minLonWest;
+ return dist180 < dist0;
+ }
+
private WorldEnvelopeBuilder expandToInclude(double latitude, double longitude) {
minLat = Math.min(minLat, latitude);
maxLat = Math.max(maxLat, latitude);
diff --git a/src/main/java/org/opentripplanner/transit/model/framework/FeedScopedId.java b/src/main/java/org/opentripplanner/transit/model/framework/FeedScopedId.java
index d67ccc3d0e1..b48ff040c28 100644
--- a/src/main/java/org/opentripplanner/transit/model/framework/FeedScopedId.java
+++ b/src/main/java/org/opentripplanner/transit/model/framework/FeedScopedId.java
@@ -5,9 +5,9 @@
import java.io.Serializable;
import java.util.Arrays;
import java.util.List;
-import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.opentripplanner.framework.lang.StringUtils;
public final class FeedScopedId implements Serializable, Comparable {
@@ -59,7 +59,17 @@ public static FeedScopedId parse(String value) throws IllegalArgumentException {
* Parses a string consisting of concatenated FeedScopedIds to a List
*/
public static List parseList(String s) {
- return Arrays.stream(s.split(",")).map(FeedScopedId::parse).collect(Collectors.toList());
+ if (StringUtils.containsInvisibleCharacters(s)) {
+ throw new IllegalArgumentException(
+ "The input string '%s' contains invisible characters which is not allowed.".formatted(s)
+ );
+ }
+ return Arrays
+ .stream(s.split(","))
+ .map(String::strip)
+ .filter(i -> !i.isBlank())
+ .map(FeedScopedId::parse)
+ .toList();
}
public static boolean isValidString(String value) throws IllegalArgumentException {
diff --git a/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls b/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls
index daeb57d655e..c1189f21ba1 100644
--- a/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls
+++ b/src/main/resources/org/opentripplanner/apis/gtfs/schema.graphqls
@@ -1497,15 +1497,11 @@ type DefaultFareProduct implements FareProduct {
}
type Itinerary {
- """
- Time when the user leaves from the origin. Format: Unix timestamp in milliseconds.
- """
- startTime: Long
+ "Time when the user leaves from the origin."
+ start: OffsetDateTime
- """
- Time when the user arrives to the destination.. Format: Unix timestamp in milliseconds.
- """
- endTime: Long
+ "Time when the user leaves arrives at the destination."
+ end: OffsetDateTime
"""Duration of the trip on this itinerary, in seconds."""
duration: Long
@@ -1584,6 +1580,16 @@ type Itinerary {
and always returns an empty list. Use the leg's `fareProducts` instead.
"""
fares: [fare] @deprecated(reason: "Use the leg's `fareProducts`.")
+
+ """
+ Time when the user leaves from the origin. Format: Unix timestamp in milliseconds.
+ """
+ startTime: Long @deprecated(reason: "Use `start` instead which includes timezone information.")
+
+ """
+ Time when the user arrives to the destination. Format: Unix timestamp in milliseconds.
+ """
+ endTime: Long @deprecated(reason: "Use `end` instead which includes timezone information.")
}
"A currency"
@@ -1616,10 +1622,12 @@ type Money {
amount: Float!
}
-""""
+"""
An ISO-8601-formatted duration, i.e. `PT2H30M` for 2 hours and 30 minutes.
+
+Negative durations are formatted like `-PT10M`.
"""
-scalar Duration
+scalar Duration @specifiedBy(url:"https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)")
type RideHailingProvider {
"The ID of the ride hailing provider."
@@ -1640,30 +1648,46 @@ type RideHailingEstimate {
productName: String
}
-type Leg {
- """
- The date and time when this leg begins. Format: Unix timestamp in milliseconds.
- """
- startTime: Long
+"""
+An ISO-8601-formatted datetime with offset, i.e. `2023-06-13T14:30+03:00` for 2:30pm on June 13th 2023 at Helsinki's offset from UTC at that time.
+
+ISO-8601 allows many different formats but OTP will only return the profile specified in RFC3339.
+"""
+scalar OffsetDateTime @specifiedBy(url: "https://www.rfcreader.com/#rfc3339")
+"Real-time estimates for a vehicle at a certain place."
+type RealTimeEstimate {
+ time: OffsetDateTime!
"""
- The date and time when this leg ends. Format: Unix timestamp in milliseconds.
+ The delay or "earliness" of the vehicle at a certain place.
+
+ If the vehicle is early then this is a negative duration.
"""
- endTime: Long
+ delay: Duration!
+}
+
+"""
+Time information about a passenger at a certain place. May contain real-time information if
+available.
+"""
+type LegTime {
+ "The scheduled time of the event."
+ scheduledTime: OffsetDateTime!
+ "The estimated time of the event. If no real-time information is available, this is null,"
+ estimated: RealTimeEstimate
+}
+
+type Leg {
"""
- For transit leg, the offset from the scheduled departure time of the boarding
- stop in this leg, i.e. scheduled time of departure at boarding stop =
- `startTime - departureDelay`
+ The time when the leg starts including real-time information, if available.
"""
- departureDelay: Int
+ start: LegTime!
"""
- For transit leg, the offset from the scheduled arrival time of the alighting
- stop in this leg, i.e. scheduled time of arrival at alighting stop = `endTime
- - arrivalDelay`
+ The time when the leg ends including real-time information, if available.
"""
- arrivalDelay: Int
+ end: LegTime!
"""The mode (e.g. `WALK`) used when traversing this leg."""
mode: Mode
@@ -1825,6 +1849,31 @@ type Leg {
that applies to multiple legs can appear several times.
"""
fareProducts: [FareProductUse]
+
+ """
+ The date and time when this leg begins. Format: Unix timestamp in milliseconds.
+ """
+ startTime: Long @deprecated(reason: "Use `start.estimated.time` instead which contains timezone information.")
+
+ """
+ The date and time when this leg ends. Format: Unix timestamp in milliseconds.
+ """
+ endTime: Long @deprecated(reason: "Use `end.estimated.time` instead which contains timezone information.")
+
+ """
+ For transit leg, the offset from the scheduled departure time of the boarding
+ stop in this leg, i.e. scheduled time of departure at boarding stop =
+ `startTime - departureDelay`
+ """
+ departureDelay: Int @deprecated(reason: "Use `end.estimated.delay` instead.")
+
+ """
+ For transit leg, the offset from the scheduled arrival time of the alighting
+ stop in this leg, i.e. scheduled time of arrival at alighting stop = `endTime
+ - arrivalDelay`
+ """
+ arrivalDelay: Int @deprecated(reason: "Use `start.estimated.delay` instead.")
+
}
"""A span of time."""
@@ -2260,14 +2309,16 @@ type Place {
lon: Float!
"""
- The time the rider will arrive at the place. Format: Unix timestamp in milliseconds.
+ The time the rider will arrive at the place. This also includes real-time information
+ if available.
"""
- arrivalTime: Long!
+ arrival: LegTime
"""
- The time the rider will depart the place. Format: Unix timestamp in milliseconds.
+ The time the rider will depart the place. This also includes real-time information
+ if available.
"""
- departureTime: Long!
+ departure: LegTime
"""The stop related to the place."""
stop: Stop
@@ -2305,6 +2356,16 @@ type Place {
"""The car parking related to the place"""
carPark: CarPark @deprecated(reason: "carPark is deprecated. Use vehicleParking instead.")
+
+ """
+ The time the rider will arrive at the place. Format: Unix timestamp in milliseconds.
+ """
+ arrivalTime: Long! @deprecated(reason: "Use `arrival` which includes timezone information.")
+
+ """
+ The time the rider will depart the place. Format: Unix timestamp in milliseconds.
+ """
+ departureTime: Long! @deprecated(reason: "Use `departure` which includes timezone information.")
}
type placeAtDistance implements Node {
diff --git a/src/test/java/org/opentripplanner/apis/gtfs/DurationScalarTest.java b/src/test/java/org/opentripplanner/apis/gtfs/DurationScalarTest.java
new file mode 100644
index 00000000000..28d1ad08376
--- /dev/null
+++ b/src/test/java/org/opentripplanner/apis/gtfs/DurationScalarTest.java
@@ -0,0 +1,41 @@
+package org.opentripplanner.apis.gtfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.of;
+
+import graphql.schema.CoercingSerializeException;
+import java.time.Duration;
+import java.util.List;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class DurationScalarTest {
+
+ static List durationCases() {
+ return List.of(
+ of(Duration.ofMinutes(30), "PT30M"),
+ of(Duration.ofHours(23), "PT23H"),
+ of(Duration.ofMinutes(-10), "-PT10M"),
+ of(Duration.ofMinutes(-90), "-PT1H30M"),
+ of(Duration.ofMinutes(-184), "-PT3H4M")
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("durationCases")
+ void duration(Duration duration, String expected) {
+ var string = GraphQLScalars.DURATION_SCALAR.getCoercing().serialize(duration);
+ assertEquals(expected, string);
+ }
+
+ @Test
+ void nonDuration() {
+ Assertions.assertThrows(
+ CoercingSerializeException.class,
+ () -> GraphQLScalars.DURATION_SCALAR.getCoercing().serialize(new Object())
+ );
+ }
+}
diff --git a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLScalarsTest.java b/src/test/java/org/opentripplanner/apis/gtfs/GeoJsonScalarTest.java
similarity index 59%
rename from src/test/java/org/opentripplanner/apis/gtfs/GraphQLScalarsTest.java
rename to src/test/java/org/opentripplanner/apis/gtfs/GeoJsonScalarTest.java
index 90b35a31b9f..cc88e22d074 100644
--- a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLScalarsTest.java
+++ b/src/test/java/org/opentripplanner/apis/gtfs/GeoJsonScalarTest.java
@@ -3,29 +3,12 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
-import graphql.schema.CoercingSerializeException;
-import java.time.Duration;
-import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.opentripplanner.framework.json.ObjectMappers;
-class GraphQLScalarsTest {
-
- @Test
- void duration() {
- var string = GraphQLScalars.durationScalar.getCoercing().serialize(Duration.ofMinutes(30));
- assertEquals("PT30M", string);
- }
-
- @Test
- void nonDuration() {
- Assertions.assertThrows(
- CoercingSerializeException.class,
- () -> GraphQLScalars.durationScalar.getCoercing().serialize(new Object())
- );
- }
+class GeoJsonScalarTest {
@Test
void geoJson() throws JsonProcessingException {
@@ -38,7 +21,7 @@ void geoJson() throws JsonProcessingException {
new Coordinate(0, 0),
}
);
- var geoJson = GraphQLScalars.geoJsonScalar.getCoercing().serialize(polygon);
+ var geoJson = GraphQLScalars.GEOJSON_SCALAR.getCoercing().serialize(polygon);
var jsonNode = ObjectMappers
.ignoringExtraFields()
diff --git a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java b/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java
index d89afea6f9d..f630e0cee3c 100644
--- a/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java
+++ b/src/test/java/org/opentripplanner/apis/gtfs/GraphQLIntegrationTest.java
@@ -61,7 +61,6 @@
import org.opentripplanner.routing.alertpatch.TimePeriod;
import org.opentripplanner.routing.alertpatch.TransitAlert;
import org.opentripplanner.routing.api.request.RouteRequest;
-import org.opentripplanner.routing.core.FareType;
import org.opentripplanner.routing.graph.Graph;
import org.opentripplanner.routing.graphfinder.GraphFinder;
import org.opentripplanner.routing.graphfinder.NearbyStop;
@@ -86,6 +85,7 @@
import org.opentripplanner.transit.model.network.TripPattern;
import org.opentripplanner.transit.model.site.RegularStop;
import org.opentripplanner.transit.model.site.StopLocation;
+import org.opentripplanner.transit.model.timetable.RealTimeTripTimes;
import org.opentripplanner.transit.model.timetable.TripTimesFactory;
import org.opentripplanner.transit.service.DefaultTransitService;
import org.opentripplanner.transit.service.TransitModel;
@@ -115,6 +115,7 @@ class GraphQLIntegrationTest {
.parse("2023-02-15T12:03:28+01:00")
.toInstant();
static final Instant ALERT_END_TIME = ALERT_START_TIME.plus(1, ChronoUnit.DAYS);
+ private static final int TEN_MINUTES = 10 * 60;
private static GraphQLRequestContext context;
@@ -179,6 +180,8 @@ static void setup() {
.carHail(D10m, E)
.build();
+ add10MinuteDelay(i1);
+
var busLeg = i1.getTransitLeg(1);
var railLeg = (ScheduledTransitLeg) i1.getTransitLeg(2);
@@ -280,6 +283,20 @@ public TransitAlertService getTransitAlertService() {
);
}
+ private static void add10MinuteDelay(Itinerary i1) {
+ i1.transformTransitLegs(tl -> {
+ if (tl instanceof ScheduledTransitLeg stl) {
+ var rtt = (RealTimeTripTimes) stl.getTripTimes();
+
+ for (var i = 0; i < rtt.getNumStops(); i++) {
+ rtt.updateArrivalTime(i, rtt.getArrivalTime(i) + TEN_MINUTES);
+ rtt.updateDepartureTime(i, rtt.getDepartureTime(i) + TEN_MINUTES);
+ }
+ }
+ return tl;
+ });
+ }
+
@FilePatternSource(pattern = "src/test/resources/org/opentripplanner/apis/gtfs/queries/*.graphql")
@ParameterizedTest(name = "Check GraphQL query in {0}")
void graphQL(Path path) throws IOException {
diff --git a/src/test/java/org/opentripplanner/apis/gtfs/OffsetDateTimeScalarTest.java b/src/test/java/org/opentripplanner/apis/gtfs/OffsetDateTimeScalarTest.java
new file mode 100644
index 00000000000..45c8e9de837
--- /dev/null
+++ b/src/test/java/org/opentripplanner/apis/gtfs/OffsetDateTimeScalarTest.java
@@ -0,0 +1,62 @@
+package org.opentripplanner.apis.gtfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.of;
+
+import graphql.language.StringValue;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.opentripplanner._support.time.ZoneIds;
+
+public class OffsetDateTimeScalarTest {
+
+ static final OffsetDateTime OFFSET_DATE_TIME = OffsetDateTime.of(
+ LocalDate.of(2024, 2, 4),
+ LocalTime.MIDNIGHT,
+ ZoneOffset.UTC
+ );
+
+ static List offsetDateTimeCases() {
+ return List.of(
+ of(OFFSET_DATE_TIME, "2024-02-04T00:00:00Z"),
+ of(OFFSET_DATE_TIME.plusHours(12).plusMinutes(8).plusSeconds(22), "2024-02-04T12:08:22Z"),
+ of(
+ OFFSET_DATE_TIME.atZoneSameInstant(ZoneIds.BERLIN).toOffsetDateTime(),
+ "2024-02-04T01:00:00+01:00"
+ ),
+ of(
+ OFFSET_DATE_TIME.atZoneSameInstant(ZoneIds.NEW_YORK).toOffsetDateTime(),
+ "2024-02-03T19:00:00-05:00"
+ )
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("offsetDateTimeCases")
+ void serializeOffsetDateTime(OffsetDateTime odt, String expected) {
+ var string = GraphQLScalars.OFFSET_DATETIME_SCALAR.getCoercing().serialize(odt);
+ assertEquals(expected, string);
+ }
+
+ @ParameterizedTest
+ @MethodSource("offsetDateTimeCases")
+ void parseOffsetDateTime(OffsetDateTime expected, String input) {
+ var odt = GraphQLScalars.OFFSET_DATETIME_SCALAR.getCoercing().parseValue(input);
+ assertEquals(expected, odt);
+ }
+
+ @ParameterizedTest
+ @MethodSource("offsetDateTimeCases")
+ void parseOffsetDateTimeLiteral(OffsetDateTime expected, String input) {
+ var odt = GraphQLScalars.OFFSET_DATETIME_SCALAR
+ .getCoercing()
+ .parseLiteral(new StringValue(input));
+ assertEquals(expected, odt);
+ }
+}
diff --git a/src/test/java/org/opentripplanner/apis/gtfs/mapping/RouteRequestMapperTest.java b/src/test/java/org/opentripplanner/apis/gtfs/mapping/RouteRequestMapperTest.java
index ecba3320838..22a8583d705 100644
--- a/src/test/java/org/opentripplanner/apis/gtfs/mapping/RouteRequestMapperTest.java
+++ b/src/test/java/org/opentripplanner/apis/gtfs/mapping/RouteRequestMapperTest.java
@@ -20,6 +20,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.opentripplanner._support.time.ZoneIds;
import org.opentripplanner.apis.gtfs.GraphQLRequestContext;
import org.opentripplanner.apis.gtfs.TestRoutingService;
@@ -34,7 +35,6 @@
import org.opentripplanner.service.realtimevehicles.internal.DefaultRealtimeVehicleService;
import org.opentripplanner.service.vehiclerental.internal.DefaultVehicleRentalService;
import org.opentripplanner.street.search.TraverseMode;
-import org.opentripplanner.test.support.VariableSource;
import org.opentripplanner.transit.service.DefaultTransitService;
import org.opentripplanner.transit.service.TransitModel;
@@ -91,25 +91,30 @@ void parkingFilters() {
testParkingFilters(routeRequest.preferences().parking(TraverseMode.BICYCLE));
}
- static Stream banningCases = Stream.of(
- of(Map.of(), "[TransitFilterRequest{}]"),
- of(
- Map.of("routes", "trimet:555"),
- "[TransitFilterRequest{not: [SelectRequest{transportModes: [], routes: [trimet:555]}]}]"
- ),
- of(Map.of("agencies", ""), "[TransitFilterRequest{not: [SelectRequest{transportModes: []}]}]"),
- of(
- Map.of("agencies", "trimet:666"),
- "[TransitFilterRequest{not: [SelectRequest{transportModes: [], agencies: [trimet:666]}]}]"
- ),
- of(
- Map.of("agencies", "trimet:666", "routes", "trimet:444"),
- "[TransitFilterRequest{not: [SelectRequest{transportModes: [], routes: [trimet:444]}, SelectRequest{transportModes: [], agencies: [trimet:666]}]}]"
- )
- );
+ static Stream banningCases() {
+ return Stream.of(
+ of(Map.of(), "[TransitFilterRequest{}]"),
+ of(
+ Map.of("routes", "trimet:555"),
+ "[TransitFilterRequest{not: [SelectRequest{transportModes: [], routes: [trimet:555]}]}]"
+ ),
+ of(
+ Map.of("agencies", ""),
+ "[TransitFilterRequest{not: [SelectRequest{transportModes: []}]}]"
+ ),
+ of(
+ Map.of("agencies", "trimet:666"),
+ "[TransitFilterRequest{not: [SelectRequest{transportModes: [], agencies: [trimet:666]}]}]"
+ ),
+ of(
+ Map.of("agencies", "trimet:666", "routes", "trimet:444"),
+ "[TransitFilterRequest{not: [SelectRequest{transportModes: [], routes: [trimet:444]}, SelectRequest{transportModes: [], agencies: [trimet:666]}]}]"
+ )
+ );
+ }
@ParameterizedTest
- @VariableSource("banningCases")
+ @MethodSource("banningCases")
void banning(Map banned, String expectedFilters) {
Map arguments = Map.of("banned", banned);
@@ -119,21 +124,23 @@ void banning(Map banned, String expectedFilters) {
assertEquals(expectedFilters, routeRequest.journey().transit().filters().toString());
}
- static Stream transportModesCases = Stream.of(
- of(List.of(), "[ExcludeAllTransitFilter{}]"),
- of(List.of(mode("BICYCLE")), "[ExcludeAllTransitFilter{}]"),
- of(
- List.of(mode("BUS")),
- "[TransitFilterRequest{select: [SelectRequest{transportModes: [BUS, COACH]}]}]"
- ),
- of(
- List.of(mode("BUS"), mode("MONORAIL")),
- "[TransitFilterRequest{select: [SelectRequest{transportModes: [BUS, COACH, MONORAIL]}]}]"
- )
- );
+ static Stream transportModesCases() {
+ return Stream.of(
+ of(List.of(), "[ExcludeAllTransitFilter{}]"),
+ of(List.of(mode("BICYCLE")), "[ExcludeAllTransitFilter{}]"),
+ of(
+ List.of(mode("BUS")),
+ "[TransitFilterRequest{select: [SelectRequest{transportModes: [BUS, COACH]}]}]"
+ ),
+ of(
+ List.of(mode("BUS"), mode("MONORAIL")),
+ "[TransitFilterRequest{select: [SelectRequest{transportModes: [BUS, COACH, MONORAIL]}]}]"
+ )
+ );
+ }
@ParameterizedTest
- @VariableSource("transportModesCases")
+ @MethodSource("transportModesCases")
void modes(List