diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index ef204e5..6722a02 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -39,7 +39,7 @@ public class LibraryController { private static final Set ALLOWED_SIZES = Set.of(20, 40, 80); private static final int DEFAULT_SIZE = 20; - private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid"); + private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private final LibrarySyncService syncService; private final LibraryQueryService queryService; diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index e389074..59432a5 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -6,6 +6,8 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -48,6 +50,25 @@ public interface UserGameRepository extends JpaRepository @Param("namePattern") String namePattern, Pageable pageable); + /** + * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last + * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and + * appid breaks every tie so pages do not repeat or skip games. + */ + static Sort querySort(Sort requested) { + Sort result = Sort.unsorted(); + for (Sort.Order order : requested) { + result = result.and(switch (order.getProperty()) { + case "rating" -> Sort.by(order.nullsLast()); + case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), + "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") + .iterator().next().nullsLast()); + default -> Sort.by(order); + }); + } + return result.and(Sort.by("appid")); + } + /** * Case-insensitive "contains" pattern for {@link #findOwnedGames}; % and _ in the query match literally. */ diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 0ddb2b7..a7754b7 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -11,6 +11,7 @@ import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,8 +36,10 @@ public class LibraryQueryService { } public Page listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { + Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + UserGameRepository.querySort(pageable.getSort())); Page page = userGameRepository.findOwnedGames( - userId, status, UserGameRepository.containsPattern(nameQuery), pageable); + userId, status, UserGameRepository.containsPattern(nameQuery), stable); List appids = page.getContent().stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 4884dcf..5663464 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -1,5 +1,6 @@ package core_service.modules.library.repo; +import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.SteamGame; @@ -95,6 +96,51 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(2, page.getTotalElements()); } + private List sortedAppids(Sort sort) { + return userGameRepository.findOwnedGames(USER_ID, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) + .map(UserGame::getAppid).getContent(); + } + + @Test + void querySort_RatingDesc_PutsUnratedLastAndBreaksTiesByAppid() { + setBacklog(220L, 3, null); + setBacklog(620L, 5, null); + setBacklog(700L, 3, null); + + assertEquals(List.of(620L, 220L, 700L, 380L), sortedAppids(Sort.by(Sort.Direction.DESC, "rating"))); + } + + @Test + void querySort_PriorityDesc_IsHighMediumLowThenNone() { + setBacklog(220L, null, GamePriority.LOW); + setBacklog(380L, null, GamePriority.HIGH); + setBacklog(700L, null, GamePriority.MEDIUM); + + assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "priority"))); + } + + @Test + void querySort_EqualPlaytime_BreaksTiesByAppid() { + assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))); + } + + // unrated LAST (nullsLast) and the querySort javadoc promises "then none" — one API, two opposite null rules. + @Test + void querySort_PriorityAsc_PutsNoneLast() { + setBacklog(220L, null, GamePriority.LOW); + setBacklog(380L, null, GamePriority.HIGH); + setBacklog(700L, null, GamePriority.MEDIUM); + + assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); + } + + private void setBacklog(long appid, Integer rating, GamePriority priority) { + UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); + game.setRating(rating); + game.setPriority(priority); + userGameRepository.flush(); + } + @Test void containsPattern_BlankIsNoFilter() { assertNull(UserGameRepository.containsPattern(" "));