Public Access
feat(picker): mix similar, longest waiting and random picks
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:
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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user