From a9126d60dfd206b14670d52e4134bccc543e5505 Mon Sep 17 00:00:00 2001 From: u228298 Date: Mon, 1 Sep 2025 19:19:28 +0200 Subject: [PATCH 1/2] feat(service): relevance-based sorting for stop search Add a `StopSortStrategy` to the `PublicTransitService` interface a `stopSortStrategy` query parameter to the `/schedule/stops/autocomplete` endpoint to improve search result ordering. The default sort strategy is now `RELEVANCE`, which prioritizes matches that start with the user's query, providing a more intuitive user experience than the previous alphabetical-only sorting. The `ALPHABETICAL` strategy remains available as an option. --- .../app/controller/ScheduleController.java | 9 +- .../java/org/naviqore/app/dto/DtoMapper.java | 5 + .../naviqore/app/dto/StopSortStrategy.java | 31 +++++ .../service/PublicTransitSpringService.java | 4 +- .../controller/DummyService.java | 2 +- .../controller/ScheduleControllerTest.java | 17 +-- .../service/ScheduleInformationService.java | 7 +- .../naviqore/service/StopSortStrategy.java | 74 ++++++++++++ .../gtfs/raptor/GtfsRaptorService.java | 7 +- .../service/StopSortStrategyTest.java | 111 ++++++++++++++++++ .../gtfs/raptor/GtfsRaptorServiceIT.java | 49 +++++++- 11 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/naviqore/app/dto/StopSortStrategy.java create mode 100644 libs/public-transit-service/src/main/java/org/naviqore/service/StopSortStrategy.java create mode 100644 libs/public-transit-service/src/test/java/org/naviqore/service/StopSortStrategyTest.java diff --git a/app/src/main/java/org/naviqore/app/controller/ScheduleController.java b/app/src/main/java/org/naviqore/app/controller/ScheduleController.java index fae1136b..cb49cd44 100644 --- a/app/src/main/java/org/naviqore/app/controller/ScheduleController.java +++ b/app/src/main/java/org/naviqore/app/controller/ScheduleController.java @@ -44,9 +44,14 @@ public ScheduleInfo getScheduleInfo() { @GetMapping("/stops/autocomplete") public List getAutoCompleteStops(@RequestParam String query, @RequestParam(required = false, defaultValue = "10") int limit, - @RequestParam(required = false, defaultValue = "STARTS_WITH") SearchType searchType) { + @RequestParam(required = false, defaultValue = "CONTAINS") SearchType searchType, + @RequestParam(required = false, defaultValue = "RELEVANCE") StopSortStrategy stopSortStrategy) { ScheduleRequestValidator.validateLimit(limit); - return service.getStops(query, map(searchType)).stream().map(DtoMapper::map).limit(limit).toList(); + return service.getStops(query, map(searchType), map(stopSortStrategy)) + .stream() + .map(DtoMapper::map) + .limit(limit) + .toList(); } @Operation(summary = "Get nearest stops", description = "Retrieves a list of stops within a specified distance from a given location.") diff --git a/app/src/main/java/org/naviqore/app/dto/DtoMapper.java b/app/src/main/java/org/naviqore/app/dto/DtoMapper.java index d95dfd54..7786dc7f 100644 --- a/app/src/main/java/org/naviqore/app/dto/DtoMapper.java +++ b/app/src/main/java/org/naviqore/app/dto/DtoMapper.java @@ -4,6 +4,7 @@ import lombok.NoArgsConstructor; import org.naviqore.service.*; import org.naviqore.service.SearchType; +import org.naviqore.service.StopSortStrategy; import java.util.ArrayList; import java.util.EnumSet; @@ -64,6 +65,10 @@ public static SearchType map(org.naviqore.app.dto.SearchType searchType) { return SearchType.valueOf(searchType.name()); } + public static StopSortStrategy map(org.naviqore.app.dto.StopSortStrategy stopSortStrategy) { + return StopSortStrategy.valueOf(stopSortStrategy.name()); + } + public static org.naviqore.service.TimeType map(TimeType timeType) { return switch (timeType) { case ARRIVAL -> org.naviqore.service.TimeType.ARRIVAL; diff --git a/app/src/main/java/org/naviqore/app/dto/StopSortStrategy.java b/app/src/main/java/org/naviqore/app/dto/StopSortStrategy.java new file mode 100644 index 00000000..c0df2353 --- /dev/null +++ b/app/src/main/java/org/naviqore/app/dto/StopSortStrategy.java @@ -0,0 +1,31 @@ +package org.naviqore.app.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum StopSortStrategy { + + ALPHABETICAL("ALPHABETICAL"), + RELEVANCE("RELEVANCE"); + + private final String value; + + @JsonCreator + public static StopSortStrategy fromValue(String value) { + for (StopSortStrategy b : StopSortStrategy.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } + + @JsonValue + public String getValue() { + return value; + } + +} diff --git a/app/src/main/java/org/naviqore/app/service/PublicTransitSpringService.java b/app/src/main/java/org/naviqore/app/service/PublicTransitSpringService.java index c3fbde18..f20ed3f4 100644 --- a/app/src/main/java/org/naviqore/app/service/PublicTransitSpringService.java +++ b/app/src/main/java/org/naviqore/app/service/PublicTransitSpringService.java @@ -73,8 +73,8 @@ public boolean hasTravelModeInformation() { } @Override - public List getStops(String like, SearchType searchType) { - return delegate.getStops(like, searchType); + public List getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy) { + return delegate.getStops(like, searchType, stopSortStrategy); } @Override diff --git a/app/src/test/java/org.naviqore.app/controller/DummyService.java b/app/src/test/java/org.naviqore.app/controller/DummyService.java index 54c96eb8..1f3e6a25 100644 --- a/app/src/test/java/org.naviqore.app/controller/DummyService.java +++ b/app/src/test/java/org.naviqore.app/controller/DummyService.java @@ -167,7 +167,7 @@ public Map getIsolines(Stop source, LocalDateTime time, TimeTy } @Override - public List getStops(String like, SearchType searchType) { + public List getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy) { return STOPS.stream().map(x -> (Stop) x).toList(); } diff --git a/app/src/test/java/org.naviqore.app/controller/ScheduleControllerTest.java b/app/src/test/java/org.naviqore.app/controller/ScheduleControllerTest.java index 1115daf5..ae3d8665 100644 --- a/app/src/test/java/org.naviqore.app/controller/ScheduleControllerTest.java +++ b/app/src/test/java/org.naviqore.app/controller/ScheduleControllerTest.java @@ -11,10 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.naviqore.app.dto.Departure; -import org.naviqore.app.dto.DistanceToStop; -import org.naviqore.app.dto.SearchType; -import org.naviqore.app.dto.Stop; +import org.naviqore.app.dto.*; import org.naviqore.service.ScheduleInformationService; import org.naviqore.service.Validity; import org.naviqore.service.exception.StopNotFoundException; @@ -92,8 +89,10 @@ class AutoCompleteStops { @Test void shouldSucceedWithValidQuery() { String query = "query"; - when(scheduleInformationService.getStops(query, map(SearchType.STARTS_WITH))).thenReturn(List.of()); - List stops = scheduleController.getAutoCompleteStops(query, 10, SearchType.STARTS_WITH); + when(scheduleInformationService.getStops(query, map(SearchType.STARTS_WITH), + map(StopSortStrategy.ALPHABETICAL))).thenReturn(List.of()); + List stops = scheduleController.getAutoCompleteStops(query, 10, SearchType.STARTS_WITH, + StopSortStrategy.ALPHABETICAL); assertNotNull(stops); } @@ -101,7 +100,8 @@ void shouldSucceedWithValidQuery() { @Test void shouldFailWithNegativeLimit() { ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> scheduleController.getAutoCompleteStops("query", -10, SearchType.STARTS_WITH)); + () -> scheduleController.getAutoCompleteStops("query", -10, SearchType.STARTS_WITH, + StopSortStrategy.ALPHABETICAL)); assertEquals("Limit must be greater than 0", exception.getReason()); assertEquals(HttpStatusCode.valueOf(400), exception.getStatusCode()); @@ -110,7 +110,8 @@ void shouldFailWithNegativeLimit() { @Test void shouldFailWithZeroLimit() { ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> scheduleController.getAutoCompleteStops("query", 0, SearchType.STARTS_WITH)); + () -> scheduleController.getAutoCompleteStops("query", 0, SearchType.STARTS_WITH, + StopSortStrategy.ALPHABETICAL)); assertEquals("Limit must be greater than 0", exception.getReason()); assertEquals(HttpStatusCode.valueOf(400), exception.getStatusCode()); diff --git a/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java b/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java index 4091eabb..2511eecf 100644 --- a/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java +++ b/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java @@ -45,11 +45,12 @@ public interface ScheduleInformationService { /** * Searches for stops by name. * - * @param like the search term to match against stop names - * @param searchType the type of search to perform (STARTS_WITH, ENDS_WITH, CONTAINS, EXACT) + * @param like the search term to match against stop names + * @param searchType the type of search to perform (STARTS_WITH, ENDS_WITH, CONTAINS, EXACT) + * @param stopSortStrategy the sorting strategy for the results (e.g., RELEVANCE, ALPHABETICAL) * @return a list of stops matching the search criteria */ - List getStops(String like, SearchType searchType); + List getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy); /** * Retrieves the nearest stop to a given location. diff --git a/libs/public-transit-service/src/main/java/org/naviqore/service/StopSortStrategy.java b/libs/public-transit-service/src/main/java/org/naviqore/service/StopSortStrategy.java new file mode 100644 index 00000000..8ae80d22 --- /dev/null +++ b/libs/public-transit-service/src/main/java/org/naviqore/service/StopSortStrategy.java @@ -0,0 +1,74 @@ +package org.naviqore.service; + +import org.naviqore.gtfs.schedule.model.Stop; + +import java.util.Comparator; + +/** + * Sorting strategies for stop search results. + */ +public enum StopSortStrategy { + + /** + * Sorts stops alphabetically by name. + */ + ALPHABETICAL { + @Override + public Comparator getComparator(String query) { + return Comparator.comparing(Stop::getName); + } + }, + + /** + * Sorts stops by relevance to the search query. The relevance is determined by: + *
    + *
  • Exact match (score 0)
  • + *
  • Starts with the query (score 1)
  • + *
  • Contains the query (score 2)
  • + *
+ * Tie-breaking is done by name length (shorter is better), then alphabetically. + */ + RELEVANCE { + @Override + public Comparator getComparator(String query) { + String lowerCaseQuery = query.toLowerCase(); + + return (s1, s2) -> { + String name1 = s1.getName().toLowerCase(); + String name2 = s2.getName().toLowerCase(); + + int score1 = calculateScore(name1, lowerCaseQuery); + int score2 = calculateScore(name2, lowerCaseQuery); + + // primary sort: by relevance score (lower is better) + if (score1 != score2) { + return Integer.compare(score1, score2); + } + + // secondary sort: by name length (shorter is better) + if (name1.length() != name2.length()) { + return Integer.compare(name1.length(), name2.length()); + } + + // tertiary sort: alphabetically + return s1.getName().compareTo(s2.getName()); + }; + } + + private int calculateScore(String name, String query) { + if (name.equals(query)) { + return 0; + } + if (name.startsWith(query)) { + return 1; + } + + return 2; + } + }; + + /** + * Get the comparator for the specific strategy. + */ + public abstract Comparator getComparator(String query); +} \ No newline at end of file diff --git a/libs/public-transit-service/src/main/java/org/naviqore/service/gtfs/raptor/GtfsRaptorService.java b/libs/public-transit-service/src/main/java/org/naviqore/service/gtfs/raptor/GtfsRaptorService.java index 70739749..0dfbdc92 100644 --- a/libs/public-transit-service/src/main/java/org/naviqore/service/gtfs/raptor/GtfsRaptorService.java +++ b/libs/public-transit-service/src/main/java/org/naviqore/service/gtfs/raptor/GtfsRaptorService.java @@ -59,12 +59,13 @@ public boolean hasTravelModeInformation() { } @Override - public List getStops(String like, SearchType searchType) { - log.info("Searching for stops matching '{}', with search type '{}'", like, searchType); + public List getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy) { + log.info("Searching for stops matching '{}', with search type '{}' and sort strategy '{}'", like, searchType, + stopSortStrategy); return stopSearchIndex.search(like.toLowerCase(), TypeMapper.map(searchType)) .stream() - .sorted(Comparator.comparing(org.naviqore.gtfs.schedule.model.Stop::getName)) + .sorted(stopSortStrategy.getComparator(like)) .map(TypeMapper::map) .toList(); } diff --git a/libs/public-transit-service/src/test/java/org/naviqore/service/StopSortStrategyTest.java b/libs/public-transit-service/src/test/java/org/naviqore/service/StopSortStrategyTest.java new file mode 100644 index 00000000..d4eff91b --- /dev/null +++ b/libs/public-transit-service/src/test/java/org/naviqore/service/StopSortStrategyTest.java @@ -0,0 +1,111 @@ +package org.naviqore.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.naviqore.gtfs.schedule.model.Stop; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StopSortStrategyTest { + + @Mock + private Stop stopGstaad; + @Mock + private Stop stopGstaadBahnhof; + @Mock + private Stop stopGrundGstaad; + @Mock + private Stop stopAnother; + @Mock + private Stop stopGstaadDuplicate; + + private List testStops; + + @BeforeEach + void setUp() { + when(stopGstaad.getName()).thenReturn("Gstaad"); + when(stopGstaadBahnhof.getName()).thenReturn("Gstaad, Bahnhof"); + when(stopGrundGstaad.getName()).thenReturn("Grund b. Gstaad"); + when(stopAnother.getName()).thenReturn("Another Place"); + when(stopGstaadDuplicate.getName()).thenReturn("Gstaad"); + + testStops = new ArrayList<>( + List.of(stopGstaad, stopGstaadBahnhof, stopGrundGstaad, stopAnother, stopGstaadDuplicate)); + + Collections.shuffle(testStops); + } + + @Test + void getComparator_withRelevanceSort_shouldOrderByScoreLengthThenName() { + String query = "Gstaa"; + Comparator comparator = StopSortStrategy.RELEVANCE.getComparator(query); + + List expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad"); + + List actualOrder = testStops.stream() + .filter(s -> s.getName().toLowerCase().contains(query.toLowerCase())) + .sorted(comparator) + .map(Stop::getName) + .collect(Collectors.toList()); + + assertIterableEquals(expectedOrder, actualOrder); + } + + @Test + void getComparator_withExactMatchQuery_shouldPlaceExactMatchFirst() { + String query = "Gstaad"; + Comparator comparator = StopSortStrategy.RELEVANCE.getComparator(query); + + List expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad"); + + List actualOrder = testStops.stream() + .filter(s -> s.getName().toLowerCase().contains(query.toLowerCase())) + .sorted(comparator) + .map(Stop::getName) + .collect(Collectors.toList()); + + assertIterableEquals(expectedOrder, actualOrder); + } + + @Test + void getComparator_withMixedCaseQuery_shouldBeCaseInsensitive() { + String query = "gStAaD"; + Comparator comparator = StopSortStrategy.RELEVANCE.getComparator(query); + + List expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad"); + + List actualOrder = testStops.stream() + .filter(s -> s.getName().toLowerCase().contains(query.toLowerCase())) + .sorted(comparator) + .map(Stop::getName) + .collect(Collectors.toList()); + + assertIterableEquals(expectedOrder, actualOrder); + } + + @Test + void getComparator_withAlphabeticalSort_shouldOrderByNameOnly() { + String query = "any"; + Comparator comparator = StopSortStrategy.ALPHABETICAL.getComparator(query); + + List expectedOrder = List.of("Another Place", "Grund b. Gstaad", "Gstaad", "Gstaad", "Gstaad, Bahnhof"); + + List actualOrder = testStops.stream() + .sorted(comparator) + .map(Stop::getName) + .collect(Collectors.toList()); + + assertIterableEquals(expectedOrder, actualOrder); + } +} \ No newline at end of file diff --git a/libs/public-transit-service/src/test/java/org/naviqore/service/gtfs/raptor/GtfsRaptorServiceIT.java b/libs/public-transit-service/src/test/java/org/naviqore/service/gtfs/raptor/GtfsRaptorServiceIT.java index f421df86..263d67fc 100644 --- a/libs/public-transit-service/src/test/java/org/naviqore/service/gtfs/raptor/GtfsRaptorServiceIT.java +++ b/libs/public-transit-service/src/test/java/org/naviqore/service/gtfs/raptor/GtfsRaptorServiceIT.java @@ -20,6 +20,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -101,17 +102,59 @@ class ScheduleInformation { @Nested class SearchStopByName { + @Test + void shouldSortResultsAlphabetically() { + String query = "a"; + List expectedOrder = List.of("Amargosa Valley (Demo)", "Doing Ave / D Ave N (Demo)", + "E Main St / S Irving St (Demo)", "Furnace Creek Resort (Demo)", + "North Ave / D Ave N (Demo)", "North Ave / N A Ave (Demo)", "Nye County Airport (Demo)", + "Stagecoach Hotel & Casino (Demo)"); + + List actualOrder = service.getStops(query, SearchType.CONTAINS, + StopSortStrategy.ALPHABETICAL).stream().map(Stop::getName).collect(Collectors.toList()); + + assertFalse(actualOrder.isEmpty()); + assertEquals(expectedOrder, actualOrder, "Stops should be sorted alphabetically by name."); + } + @Test void shouldFindStopByName() { - List stops = service.getStops("Furnace Creek Resort", SearchType.CONTAINS); + List stops = service.getStops("Furnace Creek Resort", SearchType.CONTAINS, + StopSortStrategy.RELEVANCE); assertFalse(stops.isEmpty(), "Expected to find stops matching the name."); assertEquals("Furnace Creek Resort (Demo)", stops.getFirst().getName()); } @Test void shouldNotFindNonExistingStopByName() { - List stops = service.getStops("NonExistingStop", SearchType.CONTAINS); - assertTrue(stops.isEmpty(), "Expected no stops to be found."); + List stops = service.getStops("NonExistingStop", SearchType.CONTAINS, + StopSortStrategy.RELEVANCE); + assertTrue(stops.isEmpty(), "Expected no stops to be found for a non-existing name."); + } + + @Test + void shouldSortResultsByRelevance() { + String query = "North Ave"; + List expectedOrder = List.of("North Ave / D Ave N (Demo)", "North Ave / N A Ave (Demo)"); + + List actualOrder = service.getStops(query, SearchType.CONTAINS, StopSortStrategy.RELEVANCE) + .stream() + .map(Stop::getName) + .toList(); + + assertFalse(actualOrder.isEmpty(), "Expected to find stops for the query."); + assertEquals(expectedOrder, actualOrder, + "Stops should be sorted by relevance (starts with, then length)."); + } + + @Test + void shouldPlaceExactMatchFirstWithRelevanceSort() { + String query = "Bullfrog (Demo)"; + List stops = service.getStops(query, SearchType.CONTAINS, StopSortStrategy.RELEVANCE); + + assertFalse(stops.isEmpty()); + assertEquals("Bullfrog (Demo)", stops.getFirst().getName(), + "The exact match should be the first result."); } } From e7c403d9a8f9063fda04485f7cb483b737da4798 Mon Sep 17 00:00:00 2001 From: u228298 Date: Tue, 2 Sep 2025 09:10:19 +0200 Subject: [PATCH 2/2] docs(service): remove "e.g." in Javadoc comment since all options are listed --- .../java/org/naviqore/service/ScheduleInformationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java b/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java index 2511eecf..ec824648 100644 --- a/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java +++ b/libs/public-transit-service/src/main/java/org/naviqore/service/ScheduleInformationService.java @@ -47,7 +47,7 @@ public interface ScheduleInformationService { * * @param like the search term to match against stop names * @param searchType the type of search to perform (STARTS_WITH, ENDS_WITH, CONTAINS, EXACT) - * @param stopSortStrategy the sorting strategy for the results (e.g., RELEVANCE, ALPHABETICAL) + * @param stopSortStrategy the sorting strategy for the results (RELEVANCE, ALPHABETICAL) * @return a list of stops matching the search criteria */ List getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy);