diff --git a/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertionError.java b/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertionError.java index 1203b64..6a22714 100644 --- a/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertionError.java +++ b/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertionError.java @@ -35,7 +35,7 @@ public boolean isStrictTransitMatching() { return strictTransitMatching; } - /** The response used by the failed assertion, or null for the legacy constructor. */ + /** The response used by the failed assertion */ public TripPlan getTripPlan() { return tripPlan; } diff --git a/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertions.java b/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertions.java index 57fffe4..f28718f 100644 --- a/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertions.java +++ b/assertions/src/main/java/org/opentripplanner/assertions/ItineraryAssertions.java @@ -6,10 +6,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Collectors; import org.opentripplanner.client.model.Itinerary; import org.opentripplanner.client.model.Leg; +import org.opentripplanner.client.model.Place; import org.opentripplanner.client.model.TripPlan; /** @@ -69,9 +71,49 @@ public ItineraryAssertions withRouteShortName(String... shortNames) { return this; } + /** Requires the transit leg to board at a stop with one of the supplied stop codes. */ + public ItineraryAssertions withBoardingStopCode(String... stopCodes) { + addCurrentLegCriterion( + "boarding stop code '%s'".formatted(Arrays.toString(stopCodes)), + leg -> + leg.isTransit() + && stopCode(leg.from()).map(Arrays.asList(stopCodes)::contains).orElse(false)); + return this; + } + + /** Requires the transit leg to board at a stop with one of the supplied GTFS IDs. */ + public ItineraryAssertions withBoardingStopGtfsId(String... stopGtfsIds) { + addCurrentLegCriterion( + "boarding stop GTFS ID '%s'".formatted(Arrays.toString(stopGtfsIds)), + leg -> + leg.isTransit() + && stopGtfsId(leg.from()).map(Arrays.asList(stopGtfsIds)::contains).orElse(false)); + return this; + } + + /** Requires the transit leg to alight at a stop with one of the supplied stop codes. */ + public ItineraryAssertions withAlightingStopCode(String... stopCodes) { + addCurrentLegCriterion( + "alighting stop code '%s'".formatted(Arrays.toString(stopCodes)), + leg -> + leg.isTransit() + && stopCode(leg.to()).map(Arrays.asList(stopCodes)::contains).orElse(false)); + return this; + } + + /** Requires the transit leg to alight at a stop with one of the supplied GTFS IDs. */ + public ItineraryAssertions withAlightingStopGtfsId(String... stopGtfsIds) { + addCurrentLegCriterion( + "alighting stop GTFS ID '%s'".formatted(Arrays.toString(stopGtfsIds)), + leg -> + leg.isTransit() + && stopGtfsId(leg.to()).map(Arrays.asList(stopGtfsIds)::contains).orElse(false)); + return this; + } + public ItineraryAssertions withFarePrice(float price, String riderCategoryId, String mediumId) { addCurrentLegCriterion( - "fare %.2f (rider category %s, medium %s)".formatted(price, riderCategoryId, mediumId), + "fare $%.2f (rider category %s, medium %s)".formatted(price, riderCategoryId, mediumId), leg -> leg.fareProducts().stream() .filter(fp -> fp.product().riderCategory().isPresent()) @@ -83,6 +125,7 @@ public ItineraryAssertions withFarePrice(float price, String riderCategoryId, St return this; } + /** Requires this leg to be a stay-on-board continuation of the previous transit leg. */ public ItineraryAssertions interlinedWithPreviousLeg() { addCurrentLegCriterion("interlined with previous leg", Leg::interlineWithPreviousLeg); return this; @@ -94,7 +137,8 @@ public ItineraryAssertions withMode(String mode) { } /** - * Enables strict transit matching, requiring no unmatched transit legs in the chosen itinerary. + * Enables strict transit matching. Each expected leg must match the transit leg at the same + * position, and there must be no additional or missing transit legs. */ public ItineraryAssertions withStrictTransitMatching() { this.strictTransitMatching = true; @@ -103,6 +147,8 @@ public ItineraryAssertions withStrictTransitMatching() { /** Asserts that at least one itinerary in the given trip plan matches all configured criteria. */ public void assertMatches(TripPlan tripPlan) { + validateCriteria(); + List failedResults = new ArrayList<>(); for (Itinerary itinerary : tripPlan.itineraries()) { @@ -164,43 +210,28 @@ public void assertMatches(TripPlan tripPlan) { tripPlan); } - /** - * Checks each requirement to ensure some leg on the itinerary matches. - * - *

If strict transit matching is enabled, all transit legs must match some requirement. - */ + /** Checks an itinerary using exact positional or unordered matching, as configured. */ private ItineraryMatchResult matchesAllLegs(Itinerary itinerary) { - List remainingLegs = - itinerary.legs().stream().filter(Leg::isTransit).collect(Collectors.toList()); + List transitLegs = itinerary.legs().stream().filter(Leg::isTransit).toList(); + + return strictTransitMatching + ? matchesExactOrderedLegs(transitLegs) + : matchesRequiredLegs(transitLegs); + } + + private ItineraryMatchResult matchesRequiredLegs(List transitLegs) { + List remainingLegs = new ArrayList<>(transitLegs); List errors = new ArrayList<>(); List completeMatches = new ArrayList<>(); List partialMatches = new ArrayList<>(); - if (distinctLegCriteria.isEmpty()) { - throw new IllegalArgumentException("No leg criteria specified"); - } - for (var criteriaIndex = 0; criteriaIndex < distinctLegCriteria.size(); criteriaIndex++) { List criteriaSet = distinctLegCriteria.get(criteriaIndex); boolean foundMatch = false; - if (criteriaSet.isEmpty()) { - throw new IllegalArgumentException( - "No leg criteria specified for criteria set " + (criteriaIndex + 1)); - } - for (int i = 0; i < remainingLegs.size(); i++) { Leg leg = remainingLegs.get(i); - LegMatchingState state = new LegMatchingState(leg); - criteriaSet.forEach( - criterion -> { - var pass = criterion.test().test(leg); - if (pass) { - state.addMatch(criterion.message()); - } else { - state.addFailure(criterion.message()); - } - }); + LegMatchingState state = matchLeg(leg, criteriaSet); if (state.isFullMatch()) { remainingLegs.remove(i); @@ -219,27 +250,90 @@ private ItineraryMatchResult matchesAllLegs(Itinerary itinerary) { } } - List extraLegs = new ArrayList<>(); - if (strictTransitMatching && errors.isEmpty()) { - List additionalTransitLegs = remainingLegs.stream().filter(Leg::isTransit).toList(); + if (errors.isEmpty()) { + return ItineraryMatchResult.success(completeMatches); + } + + return new ItineraryMatchResult(completeMatches, partialMatches, List.of(), errors); + } - if (!additionalTransitLegs.isEmpty()) { - extraLegs.addAll(additionalTransitLegs); - String extraLegNames = - additionalTransitLegs.stream() - .map(Leg::routeDisplayName) - .collect(Collectors.joining(" ")); + private ItineraryMatchResult matchesExactOrderedLegs(List transitLegs) { + List errors = new ArrayList<>(); + List completeMatches = new ArrayList<>(); + List partialMatches = new ArrayList<>(); + + for (int criteriaIndex = 0; criteriaIndex < distinctLegCriteria.size(); criteriaIndex++) { + List criteriaSet = distinctLegCriteria.get(criteriaIndex); + if (criteriaIndex >= transitLegs.size()) { errors.add( - "Itinerary contains additional transit legs when strict matching is enabled: %s" - .formatted(extraLegNames)); + "No transit leg at position %d matching criteria: %s" + .formatted(criteriaIndex + 1, describeCriteria(criteriaSet).trim())); + continue; + } + + LegMatchingState state = matchLeg(transitLegs.get(criteriaIndex), criteriaSet); + if (state.isFullMatch()) { + completeMatches.add(state); + } else { + if (state.hasAnyMatch()) { + partialMatches.add(state); + } + errors.add( + "Transit leg at position %d does not match criteria: %s" + .formatted(criteriaIndex + 1, describeCriteria(criteriaSet).trim())); } } + List extraLegs = + transitLegs.size() > distinctLegCriteria.size() + ? List.copyOf(transitLegs.subList(distinctLegCriteria.size(), transitLegs.size())) + : List.of(); + if (!extraLegs.isEmpty()) { + String extraLegNames = + extraLegs.stream().map(Leg::routeDisplayName).collect(Collectors.joining(" ")); + errors.add( + "Itinerary contains additional transit legs when strict matching is enabled: %s" + .formatted(extraLegNames)); + } + if (errors.isEmpty()) { return ItineraryMatchResult.success(completeMatches); } return new ItineraryMatchResult(completeMatches, partialMatches, extraLegs, errors); } + + private LegMatchingState matchLeg(Leg leg, List criteriaSet) { + LegMatchingState state = new LegMatchingState(leg); + criteriaSet.forEach( + criterion -> { + if (criterion.test().test(leg)) { + state.addMatch(criterion.message()); + } else { + state.addFailure(criterion.message()); + } + }); + return state; + } + + private void validateCriteria() { + if (distinctLegCriteria.isEmpty()) { + throw new IllegalArgumentException("No leg criteria specified"); + } + + for (int i = 0; i < distinctLegCriteria.size(); i++) { + if (distinctLegCriteria.get(i).isEmpty()) { + throw new IllegalArgumentException("No leg criteria specified for criteria set " + (i + 1)); + } + } + } + + private static Optional stopCode(Place place) { + return place.stop().flatMap(stop -> stop.code()); + } + + private static Optional stopGtfsId(Place place) { + return place.stop().map(stop -> stop.id()); + } } diff --git a/assertions/src/test/java/org/opentripplanner/assertions/ItineraryAssertionsTest.java b/assertions/src/test/java/org/opentripplanner/assertions/ItineraryAssertionsTest.java index da34e1b..d8d359a 100644 --- a/assertions/src/test/java/org/opentripplanner/assertions/ItineraryAssertionsTest.java +++ b/assertions/src/test/java/org/opentripplanner/assertions/ItineraryAssertionsTest.java @@ -21,6 +21,7 @@ import org.opentripplanner.client.model.LegMode; import org.opentripplanner.client.model.Money; import org.opentripplanner.client.model.Place; +import org.opentripplanner.client.model.Stop; import org.opentripplanner.client.model.Trip; import org.opentripplanner.client.model.TripPlan; @@ -81,6 +82,150 @@ void multiLegMatchSuccess() { .assertMatches(plan)); } + @Test + void exactTransitLegsMustMatchInOrder() { + TripPlan plan = + tripPlan( + itinerary( + walkLeg(Duration.ofMinutes(5)), + transitLeg("10", "Route 10", LegMode.BUS, Duration.ofMinutes(20), List.of()), + transitLeg("1", "Line 1", LegMode.TRAM, Duration.ofMinutes(15), List.of()), + walkLeg(Duration.ofMinutes(5)))); + + assertDoesNotThrow( + () -> + new ItineraryAssertions() + .withStrictTransitMatching() + .hasLeg() + .withRouteShortName("10") + .hasLeg() + .withRouteShortName("1") + .assertMatches(plan)); + + ItineraryAssertionError error = + assertThrows( + ItineraryAssertionError.class, + () -> + new ItineraryAssertions() + .withStrictTransitMatching() + .hasLeg() + .withRouteShortName("1") + .hasLeg() + .withRouteShortName("10") + .assertMatches(plan)); + + assertThat(error.getMessage()).contains("Transit leg at position 1 does not match"); + assertThat(error.isStrictTransitMatching()).isTrue(); + } + + @Test + void nonStrictTransitLegsRemainUnordered() { + TripPlan plan = + tripPlan( + itinerary( + transitLeg("10", "Route 10", LegMode.BUS, Duration.ofMinutes(20), List.of()), + transitLeg("1", "Line 1", LegMode.TRAM, Duration.ofMinutes(15), List.of()))); + + assertDoesNotThrow( + () -> + new ItineraryAssertions() + .hasLeg() + .withRouteShortName("1") + .hasLeg() + .withRouteShortName("10") + .assertMatches(plan)); + } + + @Test + void matchesBoardingAndAlightingStopsByCodeOrGtfsId() { + Leg leg = + transitLeg( + "10", + "Route 10", + LegMode.BUS, + Duration.ofMinutes(20), + List.of(), + place("Board", "feed:board", "BOARD"), + place("Alight", "feed:alight", "ALIGHT"), + false); + TripPlan plan = tripPlan(itinerary(leg)); + + assertDoesNotThrow( + () -> + new ItineraryAssertions() + .hasLeg() + .withBoardingStopCode("BOARD") + .withBoardingStopGtfsId("feed:board") + .withAlightingStopCode("ALIGHT") + .withAlightingStopGtfsId("feed:alight") + .assertMatches(plan)); + + ItineraryAssertionError error = + assertThrows( + ItineraryAssertionError.class, + () -> + new ItineraryAssertions() + .hasLeg() + .withBoardingStopCode("ALIGHT") + .withAlightingStopGtfsId("feed:board") + .assertMatches(plan)); + + assertThat(error.getMessage()).contains("boarding stop code '[ALIGHT]'"); + assertThat(error.getMessage()).contains("alighting stop GTFS ID '[feed:board]'"); + } + + @Test + void exactMatchingChecksInterliningAtTheExpectedTransition() { + TripPlan plan = + tripPlan( + itinerary( + transitLeg( + "10", + "Route 10", + LegMode.BUS, + Duration.ofMinutes(10), + List.of(), + place("A"), + place("B"), + false), + transitLeg( + "10", + "Route 10", + LegMode.BUS, + Duration.ofMinutes(10), + List.of(), + place("B"), + place("C"), + true))); + + assertDoesNotThrow( + () -> + new ItineraryAssertions() + .withStrictTransitMatching() + .hasLeg() + .withRouteShortName("10") + .hasLeg() + .withRouteShortName("10") + .interlinedWithPreviousLeg() + .assertMatches(plan)); + + ItineraryAssertionError error = + assertThrows( + ItineraryAssertionError.class, + () -> + new ItineraryAssertions() + .withStrictTransitMatching() + .hasLeg() + .withRouteShortName("10") + .interlinedWithPreviousLeg() + .hasLeg() + .withRouteShortName("10") + .assertMatches(plan)); + + assertThat(error.getMessage()).contains("Transit leg at position 1 does not match"); + assertThat(error.getMessage()).contains("interlined with previous leg"); + } + @Test void strictMatchingRejectsExtraTransitLegs() { TripPlan plan = @@ -193,6 +338,27 @@ private static Leg transitLeg( Duration duration, List fareProducts) { String idToken = routeShortName != null ? routeShortName : routeLongName; + return transitLeg( + routeShortName, + routeLongName, + mode, + duration, + fareProducts, + place("From " + idToken), + place("To " + idToken), + false); + } + + private static Leg transitLeg( + String routeShortName, + String routeLongName, + LegMode mode, + Duration duration, + List fareProducts, + Place from, + Place to, + boolean interlineWithPreviousLeg) { + String idToken = routeShortName != null ? routeShortName : routeLongName; Route route = Route.builder() .setId("route-" + idToken) @@ -202,12 +368,12 @@ private static Leg transitLeg( .build(); return new Leg( - place("From " + idToken), - place("To " + idToken), + from, + to, START, START.plus(duration), false, - false, + interlineWithPreviousLeg, mode, duration, 1000, @@ -227,6 +393,25 @@ private static Place place(String name) { name, 10.0f, 10.0f, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } + private static Place place(String name, String stopGtfsId, String stopCode) { + Stop stop = + new Stop( + name, + stopGtfsId, + Optional.ofNullable(stopCode), + Optional.empty(), + Optional.empty(), + null); + return new Place( + name, + 10.0f, + 10.0f, + Optional.of(stop), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + private static FareProductUse fare(String riderCategoryId, String mediumId) { return new FareProductUse( "fare-" + riderCategoryId + "-" + mediumId,