feat(library): use set total playtime in sort and stats

Sort, total playtime, most played, genres, moods and the public
profile use the set total of an entry, as the library card does.
New view user_game_entry_playtime (V45) is the one SQL rule.
This commit is contained in:
lobo committed 2026-10-09 14:35:07 +02:00
1 parent 1ff1e061c7
commit 4ed8a341af
11 files changed
+253 -62

No files matched your search

@@ -3,6 +3,7 @@ package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.PsnTrophySet;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -13,6 +14,7 @@ import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.UserStatsService;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
@@ -28,10 +30,12 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -86,6 +90,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
private JdbcTemplate jdbc;
@Autowired
private UserStatsService userStatsService;
private void platinum(long appid) {
psnTrophyRepository.saveSet(USER_ID, appid, new PsnTrophySet("NPWR1_00", "trophy2", "01.00", "Set", null, "PS5",
50, 0, 0, 0, 30, true, Instant.now()));
@@ -211,6 +218,25 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats").value(nullValue()));
}
@Test
void publicProfile_PlaytimeNumbersEqualTheOwnersStats() throws Exception {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 700)", USER_ID);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = 620", USER_ID);
var own = userStatsService.getStats(USER_ID, Set.of(StatGroup.OVERVIEW, StatGroup.TOP_GAMES));
assertEquals(1000L + 900 + 60, own.overview().totalPlaytimeMinutes());
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(own.overview().totalPlaytimeMinutes()))
.andExpect(jsonPath("$.playing", hasSize(2)))
.andExpect(jsonPath("$.playing[0].name").value("Half-Life 2"))
.andExpect(jsonPath("$.playing[0].playtimeMinutes").value(1000))
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].appid").value(220))
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000));
}
@Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
@@ -15,6 +15,7 @@ import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -31,10 +32,13 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(GameLinkRepository.class)
class UserGameRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
@@ -45,6 +49,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameLinkRepository gameLinkRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -218,6 +225,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(2, page.getTotalElements());
}
@Test
void findOwnedGames_CountQueryWithFilters_MatchesTheListedEntries() {
var page = userGameRepository.findOwnedGames(USER_ID, false, GameStatus.PLAYING, null, null, ALL_SOURCES,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(1, page.getContent().size());
assertEquals(appids(GameStatus.PLAYING, null).size(), page.getTotalElements());
}
@Test
void findOwnedGames_HiddenGames_SortByTheirOwnPlaytime() {
hide(620L, 700L);
titlePlay("c1", 700L, "STEAM_OWNED", "PS5", 2000);
// hidden games are not in the entry view: they sort by their own playtime
var desc = userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
PageRequest.of(0, 20, UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))));
assertEquals(List.of(620L, 700L), desc.map(UserGame::getAppid).getContent());
assertEquals(2, desc.getTotalElements());
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
@@ -241,6 +270,49 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "priority")));
}
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, ?, ?)", USER_ID, key, state, appid, platform, minutes);
}
@Test
void findOwnedGames_SortsByPlaytimeByTheEntryTotal() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
assertEquals(List.of(380L, 700L, 620L, 220L), sortedAppids(Sort.by(Sort.Direction.ASC, "playtimeMinutes")));
}
@Test
void sumPlaytime_IsTheSumOfTheEntryTotals() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(800L, "Bloodborne", null, Instant.now()),
new SteamGame(-7L, "Manual", null, Instant.now())));
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, 800, 'BACKLOG', 'PSN', 'PS5', 50)", USER_ID);
titlePlay("c2", 800L, "LINKED", "PS5", 400);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, -7, 'BACKLOG', 'MANUAL', 'PS5', 30)", USER_ID);
titlePlay("c3", -7L, "LINKED", "PS5", 90);
long sum = userGameRepository.sumPlaytime(USER_ID);
assertEquals(1000 + 900 + 400 + 90, sum);
assertEquals(sum, gameLinkRepository.entryPlaytimes(USER_ID, List.of(220L, 380L, 620L, 700L, 800L, -7L))
.values().stream().mapToLong(Integer::longValue).sum());
}
@Test
void findEntriesPlayedInTwoWeeks_ShowsTheEntryTotal() {
titlePlay("c1", 220L, "STEAM_OWNED", "PS5", 700);
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.flush();
assertThat(userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20))
.extracting(RecentEntry::getAppid, RecentEntry::getPlaytimeMinutes)
.containsExactly(tuple(220L, 1000L));
}
@Test
void querySort_EqualPlaytime_BreaksTiesByAppid() {
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes")));
@@ -179,6 +179,35 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
.containsExactly(620L, 220L);
}
private void seedEntryTotal() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.PLAYING, Instant.now()),
game(620L, 300, GameStatus.PLAYING, Instant.now())));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID);
}
@Test
void mostPlayed_UsesTheEntryTotal() {
seedEntryTotal();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid, UserStats.GameMinutes::minutes)
.containsExactly(tuple(220L, 600L), tuple(620L, 300L));
}
@Test
void games_ListsShowTheEntryTotal() {
seedEntryTotal();
for (StatKey key : List.of(StatKey.MOST_PLAYED, StatKey.TOTAL)) {
assertThat(repository.games(USER_ID, key, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::appid, UserStats.StatGame::playtimeMinutes)
.contains(tuple(220L, 600L));
}
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
.extracting(UserStats.StatGame::appid).containsExactly(220L, 620L);
}
@Test
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
userGameRepository.saveAllAndFlush(List.of(