feat(library): filter the picker by mood

Moods come from IGDB genres, themes and game modes and are stored per game in game_mood (V9), refreshed like the time to beat. Picking a mood also refreshes the moods of the backlog in the background, so users without a Steam sync get them too.
This commit is contained in:
lobo committed 2026-09-26 07:36:10 +02:00
1 parent 5e7455db05
commit 7663e09a8c
22 files changed
+693 -15

No files matched your search

@@ -87,6 +87,76 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350));
}
@Test
void getGameTags_MapsSteamAppsToTheNamesOfTheirIgdbGame() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("external_game_source = 1"), containsString("uid = (\"620\",\"220\")"))))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("genres.name,themes.name,game_modes.name"),
containsString("id = (72,233)"))))
.andRespond(withSuccess("[{\"id\": 72, \"genres\": [{\"id\": 9, \"name\": \"Puzzle\"}],"
+ " \"themes\": [{\"id\": 1, \"name\": \"Action\"}], \"game_modes\": [{\"id\": 3, \"name\": \"Co-operative\"}]},"
+ " {\"id\": 233}]", MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.GameTags> tags = service.getGameTags(List.of(620L, 220L)).orElseThrow();
assertThat(tags).containsOnly(
Map.entry(620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of("Action"), List.of("Co-operative"))),
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
}
// REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game
// the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the
// earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union.
@Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72}, {\"id\": 233, \"themes\": [{\"id\": 19, \"name\": \"Horror\"}]}]",
MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.GameTags> tags = service.getGameTags(List.of(620L)).orElseThrow();
assertThat(tags.get(620L).themes()).contains("Horror");
}
@Test
void getGameTags_NoSteamAppKnown_AsksNoGames() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getGameTags(List.of(620L))).contains(Map.of());
igdb.verify();
}
@Test
void getGameTagsByGameIds_AsksTheGamesDirectly() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (119388)")))
.andRespond(withSuccess("[{\"id\": 119388, \"genres\": [{\"name\": \"Adventure\"}]}]", MediaType.APPLICATION_JSON));
assertThat(service.getGameTagsByGameIds(List.of(119388L))).contains(Map.of(119388L,
new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of())));
}
@Test
void getGameTags_OffOrError_IsEmpty() {
assertThat(create("", "").getGameTags(List.of(620L))).isEmpty();
service = create("client-1", "secret-1");
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getGameTags(List.of(620L))).isEmpty();
}
@Test
void getTimeToBeat_ReusesTheToken() {
expectToken();
@@ -6,10 +6,12 @@ import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,13 +50,20 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@MockitoBean
private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
@@ -68,6 +77,41 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
}
}
@Test
void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20, 30)));
verify(moodService).refreshBacklogInBackground(USER_ID);
}
@Test
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_UnknownMood_Is400() throws Exception {
mockMvc.perform(get("/api/library/picker").param("mood", "HARD"))
.andExpect(status().isBadRequest());
}
@Test
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
game(10L, GameStatus.PLAYING, null, 60);
@@ -0,0 +1,48 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MoodTest extends AbstractUnitTest {
@Test
void of_MapsIgdbNamesIgnoringCase() {
assertThat(Mood.of(List.of("Role-playing (RPG)"), List.of(), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of(), List.of("drama"), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of(), List.of(), List.of("Split screen"))).containsExactly(Mood.COOP);
assertThat(Mood.of(List.of("Card & Board Game"), List.of(), List.of())).containsExactly(Mood.CHILL);
assertThat(Mood.of(List.of("Hack and slash/Beat 'em up"), List.of(), List.of())).containsExactly(Mood.ACTION);
assertThat(Mood.of(List.of(), List.of("Horror"), List.of())).containsExactly(Mood.SCARY);
}
@Test
void of_AGameCanHaveSeveralMoods() {
assertThat(Mood.of(List.of("Shooter", "Role-playing (RPG)"), List.of("Horror"), List.of("Single player", "Co-operative")))
.containsExactlyInAnyOrder(Mood.STORY, Mood.COOP, Mood.ACTION, Mood.SCARY);
}
@Test
void of_Adventure_IsAStoryOnlyWithoutAnActionGenre() {
assertThat(Mood.of(List.of("Adventure"), List.of(), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of("Adventure", "Shooter"), List.of(), List.of())).containsExactly(Mood.ACTION);
}
@Test
void of_SurvivalWithoutHorror_IsNotScary() {
assertThat(Mood.of(List.of(), List.of("Survival", "Sandbox"), List.of())).containsExactly(Mood.CHILL);
}
@Test
void of_AHorrorGameIsNeverChill() {
assertThat(Mood.of(List.of("Puzzle"), List.of("Horror"), List.of())).containsExactly(Mood.SCARY);
}
@Test
void of_NothingThatFits_HasNoMood() {
assertThat(Mood.of(List.of("Racing", "Sport"), List.of("Science fiction"), List.of("Multiplayer"))).isEmpty();
}
}
@@ -0,0 +1,48 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void clean() {
gameMoodsRepository.deleteAll();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
}
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
}
@@ -80,6 +80,15 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(GameSource.MANUAL, manual.getSource());
}
@Test
void findBacklogAppids_OnlyActiveBacklogGamesOfTheUser() {
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG);
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.flush();
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID)));
}
@Test
void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
@@ -95,7 +104,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, List.of(), 3).size());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
}
@Test
@@ -67,6 +67,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@MockitoBean
private TimeToBeatService timeToBeatService;
@MockitoBean
private MoodService moodService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -94,12 +97,13 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
}
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() {
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
gameDetailService.getGameDetail(USER_ID, null, 620L);
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L));
org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L));
}
@Test
@@ -103,6 +103,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private TimeToBeatService timeToBeatService;
@Mock
private MoodService moodService;
@Mock
private ApplicationEventPublisher eventPublisher;
@@ -403,12 +406,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
}
@Test
void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() {
void runSync_AlsoRefreshesTimeToBeatAndMoodsOfTheGamesAddedByHand() {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
}
}
@@ -41,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private TimeToBeatService timeToBeatService;
@Mock
private MoodService moodService;
@Mock
private PlatformTransactionManager transactionManager;
@@ -49,7 +52,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
transactionManager);
moodService, transactionManager);
}
@Test
@@ -0,0 +1,104 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MoodServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameMoodsRepository gameMoodsRepository;
@Mock
private UserGameRepository userGameRepository;
@InjectMocks
private MoodService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
}
@Test
void refresh_StoresTheMoodsOfStaleGamesAndAnEmptyListForGamesWithoutData() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getGameTags(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of(), List.of("Co-operative")))));
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any());
}
@Test
void refreshBacklogInBackground_RefreshesTheBacklogOfTheUser() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of(620L));
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(Set.of(620L))).thenReturn(Optional.of(Map.of()));
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), any(Instant.class));
}
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTagsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of()))));
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), any(Instant.class));
}
@Test
void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), any());
}
@Test
void refresh_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verifyNoInteractions(gameMoodsRepository);
}
}