Public Access
feat(igdb): rank game search by popularity and help with typos
IGDB lists every e-Reader card as a game, and its own order puts obscure entries first. Core now drops e-Reader-only games, asks for 50 hits and sorts exact name first, then by rating count. When a search finds fewer than 3 games, Core searches again without doubled letters and with single words, and adds those hits by Levenshtein distance after IGDB's own hits. Also fixes a flaky test that read the time to beat before the background refresh wrote it.
This commit is contained in:
1 parent
e1d837bc78
commit
1cac7de836
8 files changed
+307
-7
No files matched your search
@@ -40,7 +40,8 @@ public final class IgdbResponses {
|
|||||||
@JsonProperty("artworks") java.util.List<Image> artworks,
|
@JsonProperty("artworks") java.util.List<Image> artworks,
|
||||||
@JsonProperty("screenshots") java.util.List<Image> screenshots,
|
@JsonProperty("screenshots") java.util.List<Image> screenshots,
|
||||||
@JsonProperty("platforms") java.util.List<Platform> platforms,
|
@JsonProperty("platforms") java.util.List<Platform> platforms,
|
||||||
@JsonProperty("external_games") java.util.List<ExternalGameSource> externalGames
|
@JsonProperty("external_games") java.util.List<ExternalGameSource> externalGames,
|
||||||
|
@JsonProperty("total_rating_count") Integer totalRatingCount
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
|||||||
@@ -49,13 +49,17 @@ public class IgdbApiService {
|
|||||||
*/
|
*/
|
||||||
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
||||||
|
|
||||||
|
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
|
||||||
|
private static final int SEARCH_FETCH_LIMIT = 50;
|
||||||
private static final int SEARCH_LIMIT = 20;
|
private static final int SEARCH_LIMIT = 20;
|
||||||
|
// IGDB lists every e-Reader card as a game of its own.
|
||||||
|
private static final String E_READER = "e-Reader / Card-e Reader";
|
||||||
// IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake,
|
// IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake,
|
||||||
// remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates.
|
// remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates.
|
||||||
private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)";
|
private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)";
|
||||||
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
|
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
|
||||||
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
|
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
|
||||||
+ "external_games.uid;";
|
+ "external_games.uid,total_rating_count;";
|
||||||
private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/";
|
private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/";
|
||||||
|
|
||||||
// IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row.
|
// IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row.
|
||||||
@@ -120,9 +124,18 @@ public class IgdbApiService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
IgdbResponses.Game[] games = query("/games", "search \"" + searchText(query) + "\"; " + GAME_FIELDS
|
String text = searchText(query);
|
||||||
+ " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_LIMIT + ";", IgdbResponses.Game[].class);
|
IgdbResponses.Game[] games = query("/games", "search \"" + text + "\"; " + GAME_FIELDS
|
||||||
return Optional.of(Arrays.stream(games).map(IgdbApiService::toGame).flatMap(Optional::stream).toList());
|
+ " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_FETCH_LIMIT + ";",
|
||||||
|
IgdbResponses.Game[].class);
|
||||||
|
return Optional.of(Arrays.stream(games)
|
||||||
|
.filter(game -> !onlyOnTheEReader(game))
|
||||||
|
.sorted(Comparator.comparing((IgdbResponses.Game game) -> !text.equalsIgnoreCase(trimmedName(game)))
|
||||||
|
.thenComparing(game -> game.totalRatingCount() == null ? 0 : game.totalRatingCount(), Comparator.reverseOrder()))
|
||||||
|
.map(IgdbApiService::toGame)
|
||||||
|
.flatMap(Optional::stream)
|
||||||
|
.limit(SEARCH_LIMIT)
|
||||||
|
.toList());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error searching IGDB games", e);
|
log.error("Error searching IGDB games", e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
@@ -176,6 +189,15 @@ public class IgdbApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean onlyOnTheEReader(IgdbResponses.Game game) {
|
||||||
|
return game.platforms() != null && !game.platforms().isEmpty()
|
||||||
|
&& game.platforms().stream().allMatch(platform -> E_READER.equalsIgnoreCase(platform.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimmedName(IgdbResponses.Game game) {
|
||||||
|
return game.name() == null ? "" : game.name().trim();
|
||||||
|
}
|
||||||
|
|
||||||
private static Optional<Game> toGame(IgdbResponses.Game game) {
|
private static Optional<Game> toGame(IgdbResponses.Game game) {
|
||||||
if (game.id() == null || game.name() == null || game.name().isBlank()) {
|
if (game.id() == null || game.name() == null || game.name().isBlank()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typo help for the IGDB game search, which only finds exact words. When a search finds too little, Core searches
|
||||||
|
* again with these variants and sorts their hits by how close the name is to what the user typed.
|
||||||
|
*/
|
||||||
|
final class FuzzySearch {
|
||||||
|
|
||||||
|
private static final int MIN_WORD_LENGTH = 4;
|
||||||
|
private static final int MAX_WORDS = 2;
|
||||||
|
|
||||||
|
private FuzzySearch() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Superr Mario" is "Super Mario"; empty when no letter is doubled.
|
||||||
|
*/
|
||||||
|
static Optional<String> withoutDoubledLetters(String query) {
|
||||||
|
String text = normalizeSpaces(query);
|
||||||
|
String without = text.replaceAll("(?i)(\\p{L})\\1+", "$1");
|
||||||
|
return without.equals(text) ? Optional.empty() : Optional.of(without);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first two words of 4+ letters, to search each alone; none for a single word.
|
||||||
|
*/
|
||||||
|
static List<String> words(String query) {
|
||||||
|
String[] words = normalizeSpaces(query).split(" ");
|
||||||
|
if (words.length < 2) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return Arrays.stream(words).filter(word -> word.length() >= MIN_WORD_LENGTH).limit(MAX_WORDS).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares the query with as many words of the name as the query has: "superr mario" is 1 away from
|
||||||
|
* "Super Mario Bros. 3".
|
||||||
|
*/
|
||||||
|
static int distance(String query, String name) {
|
||||||
|
List<String> queryWords = lowerWords(query);
|
||||||
|
List<String> nameWords = lowerWords(name);
|
||||||
|
String start = String.join(" ", nameWords.subList(0, Math.min(queryWords.size(), nameWords.size())));
|
||||||
|
return levenshtein(String.join(" ", queryWords), start);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int levenshtein(String a, String b) {
|
||||||
|
int[] previous = new int[b.length() + 1];
|
||||||
|
int[] current = new int[b.length() + 1];
|
||||||
|
for (int j = 0; j <= b.length(); j++) {
|
||||||
|
previous[j] = j;
|
||||||
|
}
|
||||||
|
for (int i = 1; i <= a.length(); i++) {
|
||||||
|
current[0] = i;
|
||||||
|
for (int j = 1; j <= b.length(); j++) {
|
||||||
|
int change = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
|
||||||
|
current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + change);
|
||||||
|
}
|
||||||
|
int[] swap = previous;
|
||||||
|
previous = current;
|
||||||
|
current = swap;
|
||||||
|
}
|
||||||
|
return previous[b.length()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeSpaces(String text) {
|
||||||
|
return text.trim().replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> lowerWords(String text) {
|
||||||
|
String cleaned = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
||||||
|
return cleaned.isEmpty() ? List.of() : List.of(cleaned.split(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,14 @@ import org.springframework.transaction.PlatformTransactionManager;
|
|||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next
|
* Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next
|
||||||
@@ -44,6 +47,9 @@ public class ManualGameService {
|
|||||||
|
|
||||||
public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME }
|
public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME }
|
||||||
|
|
||||||
|
private static final int MIN_HITS = 3;
|
||||||
|
private static final int MAX_HITS = 20;
|
||||||
|
|
||||||
private final IgdbApiService igdbApiService;
|
private final IgdbApiService igdbApiService;
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
@@ -67,7 +73,7 @@ public class ManualGameService {
|
|||||||
if (!igdbApiService.isEnabled()) {
|
if (!igdbApiService.isEnabled()) {
|
||||||
return Optional.of(new GameSearchDto(false, List.of()));
|
return Optional.of(new GameSearchDto(false, List.of()));
|
||||||
}
|
}
|
||||||
return igdbApiService.searchGames(query).map(games -> {
|
return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)).map(games -> {
|
||||||
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId,
|
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId,
|
||||||
games.stream().flatMap(game -> possibleKeys(game).stream()).toList()));
|
games.stream().flatMap(game -> possibleKeys(game).stream()).toList()));
|
||||||
return new GameSearchDto(true, games.stream()
|
return new GameSearchDto(true, games.stream()
|
||||||
@@ -77,6 +83,35 @@ public class ManualGameService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first.
|
||||||
|
// The variants go through the same cache, and one that fails adds nothing.
|
||||||
|
private List<IgdbApiService.Game> withTypoHelp(String query, List<IgdbApiService.Game> found) {
|
||||||
|
if (found.size() >= MIN_HITS) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
List<String> variants = new ArrayList<>();
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
FuzzySearch.withoutDoubledLetters(query).ifPresent(variants::add);
|
||||||
|
}
|
||||||
|
variants.addAll(FuzzySearch.words(query));
|
||||||
|
|
||||||
|
Set<Long> seen = new HashSet<>();
|
||||||
|
found.forEach(game -> seen.add(game.igdbId()));
|
||||||
|
List<IgdbApiService.Game> extra = new ArrayList<>();
|
||||||
|
for (String variant : variants) {
|
||||||
|
if (found.size() + extra.size() >= MIN_HITS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
igdbApiService.searchGames(variant).orElse(List.of()).stream()
|
||||||
|
.filter(game -> seen.add(game.igdbId()))
|
||||||
|
.forEach(extra::add);
|
||||||
|
}
|
||||||
|
return Stream.concat(found.stream(),
|
||||||
|
extra.stream().sorted(Comparator.comparingInt(game -> FuzzySearch.distance(query, game.name()))))
|
||||||
|
.limit(MAX_HITS)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns
|
* Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns
|
||||||
* it there, and it keeps one row. A game the user removed before comes back with its old status and notes.
|
* it there, and it keeps one row. A game the user removed before comes back with its old status and notes.
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
expectToken();
|
expectToken();
|
||||||
igdb.expect(requestTo(containsString("/games")))
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
.andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"),
|
.andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"),
|
||||||
containsString("game_type = (0,4,7,8,9,10,11)"), containsString("limit 20"))))
|
containsString("game_type = (0,4,7,8,9,10,11)"), containsString("total_rating_count"),
|
||||||
|
containsString("limit 50"))))
|
||||||
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
|
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
List<IgdbApiService.Game> games = service.searchGames(" zelda ").orElseThrow();
|
List<IgdbApiService.Game> games = service.searchGames(" zelda ").orElseThrow();
|
||||||
@@ -206,6 +207,32 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
|
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
|
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Super Super Super\"},"
|
||||||
|
+ " {\"id\": 2, \"name\": \"Super Mario Galaxy 2\", \"total_rating_count\": 900},"
|
||||||
|
+ " {\"id\": 3, \"name\": \"Super Mario Bros.\", \"total_rating_count\": 1500},"
|
||||||
|
+ " {\"id\": 4, \"name\": \"Super Mario\", \"total_rating_count\": 5}]", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId)
|
||||||
|
.containsExactly(4L, 3L, 2L, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchGames_LeavesOutGamesOnlyOnTheEReader() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
|
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Mario Advance 4: Super Mario Bros. 3-e - Para Beetle Challenge\","
|
||||||
|
+ " \"platforms\": [{\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]},"
|
||||||
|
+ " {\"id\": 2, \"name\": \"Super Mario Advance 4\", \"platforms\": [{\"id\": 24, \"name\": \"Game Boy Advance\", \"abbreviation\": \"GBA\"},"
|
||||||
|
+ " {\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]}]", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId)
|
||||||
|
.containsExactly(2L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchGames_OnlyACover_UsesTheCover() {
|
void searchGames_OnlyACover_UsesTheCover() {
|
||||||
expectToken();
|
expectToken();
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
|
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
|
||||||
add(119388, "Switch");
|
add(119388, "Switch");
|
||||||
|
timeToBeatStored(-119388L);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
|
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FuzzySearchTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutDoubledLetters_KeepsOneLetterOfEachRun() {
|
||||||
|
assertThat(FuzzySearch.withoutDoubledLetters("Superr Marioo")).contains("Super Mario");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutDoubledLetters_NothingDoubled_IsEmpty() {
|
||||||
|
assertThat(FuzzySearch.withoutDoubledLetters("Zelda Breath")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void words_SkipShortWordsAndStopAtTwo() {
|
||||||
|
assertThat(FuzzySearch.words("of the Legend Zelda Breath")).containsExactly("Legend", "Zelda");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void words_OneWord_HasNone() {
|
||||||
|
assertThat(FuzzySearch.words("Zelda")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void distance_ComparesWithTheStartOfTheNameIgnoringCase() {
|
||||||
|
assertThat(FuzzySearch.distance("superr mario", "Super Mario Bros. 3")).isEqualTo(1);
|
||||||
|
assertThat(FuzzySearch.distance("mass efect", "Mass Effect")).isEqualTo(1);
|
||||||
|
assertThat(FuzzySearch.distance("mass efect", "Mass Destruction")).isGreaterThan(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void levenshtein_CountsInsertsDeletesAndChanges() {
|
||||||
|
assertThat(FuzzySearch.levenshtein("kitten", "sitting")).isEqualTo(3);
|
||||||
|
assertThat(FuzzySearch.levenshtein("", "abc")).isEqualTo(3);
|
||||||
|
assertThat(FuzzySearch.levenshtein("same", "same")).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ManualGameServiceTest extends AbstractUnitTest {
|
class ManualGameServiceTest extends AbstractUnitTest {
|
||||||
@@ -76,4 +80,93 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
|
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IgdbApiService.Game game(long igdbId, String name) {
|
||||||
|
return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_TypoWithADoubledLetter_FindsTheGameWithoutIt() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames("Superr Mario")).thenReturn(Optional.of(List.of()));
|
||||||
|
when(igdbApiService.searchGames("Super Mario")).thenReturn(Optional.of(List.of(
|
||||||
|
game(3, "Super Mario Bros. 3"), game(1, "Super Mario Bros."), game(2, "Super Mario 64"))));
|
||||||
|
|
||||||
|
List<String> names = service.search(USER_ID, "Superr Mario").orElseThrow().results().stream()
|
||||||
|
.map(hit -> hit.name()).toList();
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Super Mario Bros. 3", "Super Mario Bros.", "Super Mario 64");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_TypoInOneWord_SearchesTheWordsAloneAndPutsTheClosestNameFirst() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
|
||||||
|
when(igdbApiService.searchGames("Mass")).thenReturn(Optional.of(List.of(
|
||||||
|
game(1, "Mass Destruction"), game(2, "Mass Effect"))));
|
||||||
|
when(igdbApiService.searchGames("Efect")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
List<String> names = service.search(USER_ID, "Mass Efect").orElseThrow().results().stream()
|
||||||
|
.map(hit -> hit.name()).toList();
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Mass Effect", "Mass Destruction");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_EnoughHits_AsksIgdbOnce() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(
|
||||||
|
game(1, "Zelda A"), game(2, "Zelda B"), game(3, "Zelda C"))));
|
||||||
|
|
||||||
|
assertThat(service.search(USER_ID, "zelda").orElseThrow().results()).hasSize(3);
|
||||||
|
verify(igdbApiService, times(1)).searchGames(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_TheGameIgdbFoundStaysBeforeTheTypoHits() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames("Breath of the Wild")).thenReturn(Optional.of(List.of(
|
||||||
|
game(1, "The Legend of Zelda: Breath of the Wild"))));
|
||||||
|
when(igdbApiService.searchGames("Breath")).thenReturn(Optional.of(List.of(
|
||||||
|
game(2, "Breath of Fire"), game(3, "Breath of Fire II"))));
|
||||||
|
|
||||||
|
List<String> names = service.search(USER_ID, "Breath of the Wild").orElseThrow().results().stream()
|
||||||
|
.map(hit -> hit.name()).toList();
|
||||||
|
|
||||||
|
assertThat(names).startsWith("The Legend of Zelda: Breath of the Wild");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_FewHits_KeepTheOrderOfTheSearch() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
|
||||||
|
when(igdbApiService.searchGames("zelda wild")).thenReturn(Optional.of(List.of(
|
||||||
|
game(1, "The Legend of Zelda: Breath of the Wild"), game(2, "Zelda: The Wand of Gamelon"))));
|
||||||
|
|
||||||
|
List<String> names = service.search(USER_ID, "zelda wild").orElseThrow().results().stream()
|
||||||
|
.map(hit -> hit.name()).toList();
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("The Legend of Zelda: Breath of the Wild", "Zelda: The Wand of Gamelon");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_IgdbFailed_IsEmptyEvenWithoutAFallback() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThat(service.search(USER_ID, "Superr Mario")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_SomeHits_TriesNoDoubledLetterVariantAndAtMostTwoWords() {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
|
||||||
|
when(igdbApiService.searchGames("Hollow Knight Silksong")).thenReturn(Optional.of(List.of(game(1, "Hollow Knight"))));
|
||||||
|
|
||||||
|
service.search(USER_ID, "Hollow Knight Silksong");
|
||||||
|
|
||||||
|
verify(igdbApiService, never()).searchGames("Holow Knight Silksong");
|
||||||
|
verify(igdbApiService, never()).searchGames("Silksong");
|
||||||
|
verify(igdbApiService, times(3)).searchGames(anyString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user