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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user