feat(library): count linked editions once in pile and stats

All game counts read the counted_user_game view, which keeps only the
most advanced edition of a linked set (older edition wins a tie). Add
the hideDuplicates filter to the library query.
This commit is contained in:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent e806670d26
commit 221b5594c5
14 files changed
+211 -38

No files matched your search

@@ -174,6 +174,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
void link_ChangesEveryUsersNumbersAndShowsTheEditions() throws Exception {
requestAs(USER_A, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2));
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
queue().andExpect(jsonPath("$.content", hasSize(0)));
@@ -185,6 +186,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.editions[0].appid").value(GAME_2));
mockMvc.perform(as(get("/api/library/games"), user, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].editions", hasItem(2)));
mockMvc.perform(as(get("/api/library/summary"), user, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.byStatus.BACKLOG").value(1));
}
requestAs(USER_A, GAME, GAME_2).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("ALREADY_LINKED"));
linkAs(Role.AUDITOR, GAME_2, GAME).andExpect(status().isConflict());
@@ -267,4 +271,14 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.content", hasSize(0))).andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.page").value(1));
}
@Test
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games?hideDuplicates=true"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1));
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(2));
}
}
@@ -73,6 +73,16 @@ class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
}
@Test
void writeAll_ALinkedSetCountsOnce() {
userGameRepository.saveAllAndFlush(List.of(game(1L, 220L, 0, GameStatus.BACKLOG), game(1L, 620L, 0, GameStatus.BACKLOG)));
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)");
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
}
@Test
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Propagation;
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private SteamGameRepository steamGameRepository;
@@ -65,7 +69,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -194,7 +198,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -202,7 +206,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -253,7 +257,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@@ -280,7 +284,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
hide(620L, 700L);
assertEquals(List.of(220L, 380L), appids(null, null));
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null,
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, false,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
}
@@ -345,6 +349,41 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
}
private void link(long... appids) {
jdbc.update("delete from game_link");
for (long appid : appids) {
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
}
}
@Test
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
link(380L, 700L);
userGameRepository.flush();
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, true,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(List.of(220L, 380L, 620L, 700L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
}
@Test
void backlogEditionOfAPlayingSet_IsNeverPicked() {
link(220L, 380L);
userGameRepository.flush();
for (int i = 0; i < 20; i++) {
assertEquals(700L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
}
assertEquals(List.of(700L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
.stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 2), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
}
@Test
void countNeverPlayed_CountsOnlyOwnActiveGamesWithoutPlaytime() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
@@ -94,6 +94,48 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
}
private void link(long... appids) {
for (long appid : appids) {
jdbc.update("insert into game_link (appid, set_id) values (?, 1)", appid);
}
}
@Test
void pileTimes_ALinkedSetCountsOnce() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()),
new GameTimeToBeat(620L, 300, 500, 800, Instant.now())));
link(220L, 620L);
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
}
@Test
void oldestUnplayed_LeavesOutABacklogEditionOfAPlayedSet() {
userGameRepository.saveAllAndFlush(List.of(
game(620L, 0, GameStatus.BACKLOG, Instant.EPOCH),
game(700L, 60, GameStatus.PLAYING, Instant.now()),
game(220L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z"))));
link(620L, 700L);
assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(220L);
}
@Test
void finishedPerYear_ALinkedSetCountsOnce() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.BACKLOG, Instant.now()),
game(620L, 300, GameStatus.BACKLOG, Instant.now())));
setStatus(220L, "FINISHED");
setStatus(620L, "FINISHED");
link(220L, 620L);
assertThat(repository.finishedPerYear(USER_ID, BERLIN))
.containsExactly(new UserStats.YearCount(LocalDate.now(BERLIN).getYear(), 1));
}
@Test
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
@@ -66,6 +66,22 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
assertEquals(0, statsRepository.biggestBacklog());
}
@Test
void aLinkedSet_CountsOnceInLibraryBacklogAndFinishedPerYear() {
jdbc.update("insert into game_link (appid, set_id) values (220, 1), (1000, 1)");
userGameRepository.saveAllAndFlush(List.of(new UserGame(5L, 220L, 0, Instant.now(), Instant.now()),
new UserGame(5L, 1000L, 0, Instant.now(), Instant.now())));
assertEquals(1, statsRepository.biggestLibrary());
assertEquals(1, statsRepository.biggestBacklog());
assertEquals(2, statsRepository.countDistinctGames());
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5");
assertEquals(1L, statsRepository.finishedPerYear("Europe/Berlin").stream()
.mapToLong(row -> ((Number) row.get("games")).longValue()).sum());
}
@Test
void hiddenGames_LeaveTheTopLists() {
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));