feat(picker): mix similar, longest waiting and random picks
Build, Test & Push Core Service / quality-check (push) Successful in 2m37s
Build, Test & Push Core Service / docker (push) Successful in 1m45s

Picker slots rotate three sources: backlog games that share moods with
one of the top 5 played games, oldest first seen, and random. Each game
carries a reason (source, anchor game, first seen). Empty slots fall
back to random. Issue #28.
This commit is contained in:
lobo committed 2026-10-05 16:13:19 +02:00
1 parent 10b7dc2272
commit 33b230ee91
11 files changed
+448 -22

No files matched your search

@@ -20,6 +20,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.Mockito.verify;
@@ -35,6 +36,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
private static final long USER_ID = 1L;
// One shared first import, so the longest-waiting slot does not depend on the insert order.
private static final Instant SEEN = Instant.parse("2024-03-12T10:00:00Z");
@Autowired
private MockMvc mockMvc;
@@ -63,8 +66,13 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
game(appid, status, priority, normallyMinutes, 0, SEEN);
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes, int playtime,
Instant firstSeenAt) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now());
UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, Instant.now());
game.setStatus(status);
game.setPriority(priority);
userGameRepository.save(game);
@@ -190,6 +198,49 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
.andExpect(jsonPath("$.games[0].appid").value(30));
}
@Test
void picker_ThreeSlots_OneGamePerSourceWithReason() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z"));
game(20L, GameStatus.BACKLOG, null, null, 0, SEEN);
game(30L, GameStatus.BACKLOG, null, null, 0, SEEN);
gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(30L, new String[]{"STORY"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(3)))
.andExpect(jsonPath("$.games[*].reason.source", contains("SIMILAR", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.games[0].appid").value(30))
.andExpect(jsonPath("$.games[0].reason.anchorAppid").value(1))
.andExpect(jsonPath("$.games[0].reason.anchorName").value("Game 1"))
.andExpect(jsonPath("$.games[0].reason.anchorPlaytimeMinutes").value(600))
.andExpect(jsonPath("$.games[1].appid").value(10))
.andExpect(jsonPath("$.games[1].reason.firstSeenAt").exists())
.andExpect(jsonPath("$.games[2].appid").value(20));
}
@Test
void picker_NoPlaytime_SimilarSlotFallsBackToRandom() throws Exception {
game(10L, GameStatus.BACKLOG, null, null, 0, Instant.parse("2020-01-01T00:00:00Z"));
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(3)))
.andExpect(jsonPath("$.games[*].reason.source", containsInAnyOrder("LONGEST", "RANDOM", "RANDOM")));
}
@Test
void picker_FewerBacklogGamesThanCount_NoDuplicates() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("count", "3"))
.andExpect(jsonPath("$.games", hasSize(2)))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
}
@Test
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
for (long appid = 1; appid <= 12; appid++) {
@@ -0,0 +1,79 @@
package core_service.modules.library.domain;
import core_service.modules.library.domain.PickMix.Pick;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import static core_service.modules.library.domain.PickSource.LONGEST;
import static core_service.modules.library.domain.PickSource.RANDOM;
import static core_service.modules.library.domain.PickSource.SIMILAR;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PickMixTest extends AbstractUnitTest {
private static UserGame game(long appid) {
return new UserGame(1L, appid, 0, Instant.now(), Instant.now());
}
private static List<UserGame> games(long... appids) {
return Arrays.stream(appids).mapToObj(PickMixTest::game).toList();
}
private static List<PickSource> sources(List<Pick> picks) {
return picks.stream().map(Pick::source).toList();
}
private static List<Long> appids(List<Pick> picks) {
return picks.stream().map(p -> p.game().getAppid()).toList();
}
@Test
void mix_ThreeSources_OneGameEachInSlotOrder() {
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), 3);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(1L, 2L, 3L), appids(picks));
}
@Test
void mix_MoreSlotsThanSources_RotationContinues() {
List<Pick> picks = PickMix.mix(games(1, 4, 7), games(2, 5), games(3, 6), 7);
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, SIMILAR, LONGEST, RANDOM, SIMILAR), sources(picks));
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), appids(picks));
}
@Test
void mix_SameGameFirstInAllLists_AppearsOnce() {
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), 3);
assertEquals(List.of(1L, 5L, 6L), appids(picks));
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
}
@Test
void mix_EmptySimilar_SlotComesFromRandom() {
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), 3);
assertEquals(List.of(RANDOM, LONGEST, RANDOM), sources(picks));
assertEquals(List.of(3L, 2L, 4L), appids(picks));
}
@Test
void mix_ListsShorterThanCount_ReturnsAllDistinctGames() {
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), 10);
assertEquals(List.of(1L, 2L, 3L), appids(picks).stream().sorted().toList());
assertEquals(3, picks.size());
}
@Test
void mix_AllEmpty_ReturnsNothing() {
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), 3).isEmpty());
}
}
@@ -4,6 +4,7 @@ import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -47,6 +48,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@@ -464,4 +468,83 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
steamGameRepository.deleteAll();
}
}
private void addGame(long appid, GameStatus status, GamePriority priority, Instant firstSeenAt, int playtime, String... moods) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, Instant.now());
game.setStatus(status);
game.setPriority(priority);
userGameRepository.save(game);
if (moods.length > 0) {
gameMoodsRepository.writeMoods(appid, moods, Instant.now());
}
userGameRepository.flush();
}
private static List<Long> ids(List<UserGame> games) {
return games.stream().map(UserGame::getAppid).toList();
}
@Test
void findSimilarGames_OrdersBySharedMoodsAndNeverReturnsTheAnchor() {
hide(380L, 700L);
Instant now = Instant.now();
addGame(1001L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP");
addGame(1002L, GameStatus.BACKLOG, null, now, 0, "STORY");
addGame(1003L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP", "CHILL");
addGame(1004L, GameStatus.BACKLOG, null, now, 0, "SCARY");
addGame(1005L, GameStatus.BACKLOG, null, now, 0);
List<UserGame> similar = userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10);
assertEquals(List.of(1003L, 1002L), ids(similar));
}
@Test
void findSimilarGames_AnchorWithoutMoods_ReturnsNothing() {
hide(380L, 700L);
addGame(1001L, GameStatus.PLAYING, null, Instant.now(), 100);
addGame(1002L, GameStatus.BACKLOG, null, Instant.now(), 0, "STORY");
assertTrue(userGameRepository.findSimilarGames(USER_ID, 1001L, null, null, List.of(), 10).isEmpty());
}
@Test
void findLongestWaitingGames_OldestFirstThenPriority() {
hide(380L, 700L);
Instant old = Instant.parse("2020-01-01T00:00:00Z");
Instant newer = Instant.parse("2024-01-01T00:00:00Z");
addGame(1001L, GameStatus.BACKLOG, GamePriority.LOW, newer, 0);
addGame(1002L, GameStatus.BACKLOG, null, old, 0);
addGame(1003L, GameStatus.BACKLOG, GamePriority.HIGH, old, 0);
assertEquals(List.of(1003L, 1002L, 1001L),
ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 10)));
assertEquals(List.of(1003L),
ids(userGameRepository.findLongestWaitingGames(USER_ID, null, null, List.of(), 1)));
}
@Test
void findMostPlayedGames_TopByPlaytimeAnyStatusWithoutZeroPlaytime() {
addGame(1001L, GameStatus.FINISHED, null, Instant.now(), 5000);
addGame(1002L, GameStatus.PLAYING, null, Instant.now(), 100);
addGame(1003L, GameStatus.BACKLOG, null, Instant.now(), 9000);
hide(1003L);
assertEquals(List.of(1001L, 620L), ids(userGameRepository.findMostPlayedGames(USER_ID, 2)));
assertEquals(List.of(1001L, 620L, 220L, 1002L), ids(userGameRepository.findMostPlayedGames(USER_ID, 10)));
}
@Test
void pickerSources_ApplyTheMoodFilterAndExcludes() {
hide(380L, 700L);
Instant now = Instant.now();
addGame(1001L, GameStatus.PLAYING, null, now, 100, "STORY", "COOP");
addGame(1002L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP");
addGame(1003L, GameStatus.BACKLOG, null, now, 0, "STORY", "COOP");
addGame(1004L, GameStatus.BACKLOG, null, now, 0, "STORY");
assertEquals(List.of(1003L), ids(userGameRepository.findSimilarGames(USER_ID, 1001L, null, Mood.COOP, List.of(1002L), 10)));
assertEquals(List.of(1003L), ids(userGameRepository.findLongestWaitingGames(USER_ID, null, Mood.COOP, List.of(1002L), 10)));
}
}