Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ public ScheduleInfo getScheduleInfo() {
@GetMapping("/stops/autocomplete")
public List<Stop> 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.")
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/org/naviqore/app/dto/DtoMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/org/naviqore/app/dto/StopSortStrategy.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ public boolean hasTravelModeInformation() {
}

@Override
public List<Stop> getStops(String like, SearchType searchType) {
return delegate.getStops(like, searchType);
public List<Stop> getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy) {
return delegate.getStops(like, searchType, stopSortStrategy);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public Map<Stop, Connection> getIsolines(Stop source, LocalDateTime time, TimeTy
}

@Override
public List<Stop> getStops(String like, SearchType searchType) {
public List<Stop> getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy) {
return STOPS.stream().map(x -> (Stop) x).toList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,16 +89,19 @@ class AutoCompleteStops {
@Test
void shouldSucceedWithValidQuery() {
String query = "query";
when(scheduleInformationService.getStops(query, map(SearchType.STARTS_WITH))).thenReturn(List.of());
List<Stop> stops = scheduleController.getAutoCompleteStops(query, 10, SearchType.STARTS_WITH);
when(scheduleInformationService.getStops(query, map(SearchType.STARTS_WITH),
map(StopSortStrategy.ALPHABETICAL))).thenReturn(List.of());
List<Stop> stops = scheduleController.getAutoCompleteStops(query, 10, SearchType.STARTS_WITH,
StopSortStrategy.ALPHABETICAL);

assertNotNull(stops);
}

@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());
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (RELEVANCE, ALPHABETICAL)
* @return a list of stops matching the search criteria
*/
List<Stop> getStops(String like, SearchType searchType);
List<Stop> getStops(String like, SearchType searchType, StopSortStrategy stopSortStrategy);

/**
* Retrieves the nearest stop to a given location.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Stop> getComparator(String query) {
return Comparator.comparing(Stop::getName);
}
},

/**
* Sorts stops by relevance to the search query. The relevance is determined by:
* <ul>
* <li>Exact match (score 0)</li>
* <li>Starts with the query (score 1)</li>
* <li>Contains the query (score 2)</li>
* </ul>
* Tie-breaking is done by name length (shorter is better), then alphabetically.
*/
RELEVANCE {
@Override
public Comparator<Stop> 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<Stop> getComparator(String query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@ public boolean hasTravelModeInformation() {
}

@Override
public List<Stop> getStops(String like, SearchType searchType) {
log.info("Searching for stops matching '{}', with search type '{}'", like, searchType);
public List<Stop> 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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Stop> 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<Stop> comparator = StopSortStrategy.RELEVANCE.getComparator(query);

List<String> expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad");

List<String> 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<Stop> comparator = StopSortStrategy.RELEVANCE.getComparator(query);

List<String> expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad");

List<String> 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<Stop> comparator = StopSortStrategy.RELEVANCE.getComparator(query);

List<String> expectedOrder = List.of("Gstaad", "Gstaad", "Gstaad, Bahnhof", "Grund b. Gstaad");

List<String> 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<Stop> comparator = StopSortStrategy.ALPHABETICAL.getComparator(query);

List<String> expectedOrder = List.of("Another Place", "Grund b. Gstaad", "Gstaad", "Gstaad", "Gstaad, Bahnhof");

List<String> actualOrder = testStops.stream()
.sorted(comparator)
.map(Stop::getName)
.collect(Collectors.toList());

assertIterableEquals(expectedOrder, actualOrder);
}
}
Loading