feat(igdb): rank game search by popularity and help with typos
Build, Test & Push Core Service / quality-check (push) Successful in 49s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

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:
lobo committed 2026-09-26 06:29:57 +02:00
1 parent e1d837bc78
commit 1cac7de836
8 files changed
+307 -7

No files matched your search

@@ -194,7 +194,8 @@ class IgdbApiServiceTest extends AbstractUnitTest {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.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));
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));
}
@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
void searchGames_OnlyACover_UsesTheCover() {
expectToken();
@@ -305,6 +305,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
@Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch");
timeToBeatStored(-119388L);
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.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.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;
class ManualGameServiceTest extends AbstractUnitTest {
@@ -76,4 +80,93 @@ class ManualGameServiceTest extends AbstractUnitTest {
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());
}
}