Public Access
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:
1 parent
1ff1e061c7
commit
4ed8a341af
11 files changed
+253
-62
No files matched your search
@@ -6,8 +6,8 @@ import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One platform's play of a game entry: what the detail lists and the card totals. The SQL of
|
||||
* {@code GameLinkRepository#entryPlaytimes} follows the same rule; a test pins both.
|
||||
* One platform's play of a game entry: what the detail lists and the card totals. The view
|
||||
* {@code user_game_entry_playtime} (V45) follows the same rule; a test pins both.
|
||||
*
|
||||
* @param platform e.g. "PS5"; null when the edition has none
|
||||
* @param appid the edition the line comes from
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.Immutable;
|
||||
|
||||
/**
|
||||
* The set total of an entry, see V45; hidden rows have none.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "user_game_entry_playtime")
|
||||
@IdClass(UserGameId.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UserGameEntryPlaytime {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Column(name = "playtime_minutes", nullable = false)
|
||||
private int playtimeMinutes;
|
||||
}
|
||||
@@ -336,30 +336,16 @@ public class GameLinkRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return per given entry key, the playtime of all its editions: a row's own minutes plus the minutes of the
|
||||
* PlayStation titles on it, except that a PlayStation row with title play, or a manual row with a title of its platform,
|
||||
* counts its titles only (a PlayStation row's own minutes are their sum). Capped at the int range. Same rule as {@code PlatformPlay#total}.
|
||||
* @return per given entry key, the playtime of all its editions: the view of V45, same rule as {@code PlatformPlay#total}
|
||||
*/
|
||||
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
|
||||
Map<Long, Integer> playtimes = new HashMap<>();
|
||||
if (appids.isEmpty()) {
|
||||
return playtimes;
|
||||
}
|
||||
jdbc.query("""
|
||||
select e.appid, least(sum(case when (m.source = 'PSN' and t.minutes is not null)
|
||||
or (m.source = 'MANUAL' and exists (
|
||||
select 1 from psn_title_play x where x.user_id = :user
|
||||
and x.appid = m.appid and x.platform = m.platform)) then 0
|
||||
else m.playtime_minutes end + coalesce(t.minutes, 0)),
|
||||
2147483647) as minutes
|
||||
from user_game_entry_member e
|
||||
join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid
|
||||
left join (select appid, sum(playtime_minutes) as minutes from psn_title_play
|
||||
where user_id = :user group by appid) t on t.appid = m.appid
|
||||
where e.user_id = :user and e.appid in (:appids)
|
||||
group by e.appid
|
||||
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("minutes")); });
|
||||
jdbc.query("select appid, playtime_minutes from user_game_entry_playtime where user_id = :user "
|
||||
+ "and appid in (:appids)", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("playtime_minutes")); });
|
||||
return playtimes;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,29 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
*/
|
||||
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
|
||||
|
||||
/**
|
||||
* The filters of {@link #findOwnedGames}, shared by its value and count query. Needs the aliases {@code ug} and {@code sg}.
|
||||
*/
|
||||
String OWNED_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
|
||||
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
||||
"and (:status is null or ug.status = :status) " +
|
||||
"and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " +
|
||||
" or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " +
|
||||
" join SteamGame mg on mg.appid = p.memberAppid " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " +
|
||||
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
||||
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
|
||||
"and (:hidden = true or exists (select 1 from CountedUserGame c " +
|
||||
" where c.userId = ug.userId and c.appid = ug.appid)) " +
|
||||
"and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))";
|
||||
|
||||
/**
|
||||
* The sort text of the set total: the own minutes for a hidden row. The query needs
|
||||
* {@code left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid}.
|
||||
*/
|
||||
String ENTRY_PLAYTIME_ORDER = "coalesce(ep.playtimeMinutes, ug.playtimeMinutes)";
|
||||
|
||||
@Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
|
||||
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
|
||||
|
||||
@@ -70,24 +93,29 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
@Query("select count(c) from CountedUserGame c where c.userId = :userId")
|
||||
long countListed(@Param("userId") Long userId);
|
||||
|
||||
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
|
||||
/**
|
||||
* The sum of the set totals of the listed entries, see V45.
|
||||
*/
|
||||
@Query("select coalesce(sum(ep.playtimeMinutes), 0) from UserGameEntryPlaytime ep where ep.userId = :userId")
|
||||
long sumPlaytime(@Param("userId") Long userId);
|
||||
|
||||
@Query("select ug from UserGame ug where " + LISTED + "and ug.status in :statuses " +
|
||||
@Query("select ug from UserGame ug left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
|
||||
"where " + LISTED + "and ug.status in :statuses " +
|
||||
"and exists (select 1 from CountedUserGame c where c.userId = ug.userId and c.appid = ug.appid)")
|
||||
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* The entries played in the last two weeks, the most played first: the counted edition with the playtime of every
|
||||
* edition of the set added up, see V43.
|
||||
* The entries played in the last two weeks, the most played in them first: the counted edition, the minutes of the
|
||||
* last two weeks of every edition of the set added up (Steam only), and the set total as playtime, see V45.
|
||||
*/
|
||||
@Query(value = "select c.appid as appid, c.status as status, sum(m.playtime_minutes) as playtimeMinutes, " +
|
||||
@Query(value = "select c.appid as appid, c.status as status, ep.playtime_minutes as playtimeMinutes, " +
|
||||
"sum(m.playtime_2weeks) as playtime2Weeks " +
|
||||
"from counted_user_game c " +
|
||||
"join user_game_entry_playtime ep on ep.user_id = c.user_id and ep.appid = c.appid " +
|
||||
"join user_game_entry_member e on e.user_id = c.user_id and e.appid = c.appid " +
|
||||
"join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid " +
|
||||
"where c.user_id = :userId group by c.appid, c.status " +
|
||||
"where c.user_id = :userId group by c.appid, c.status, ep.playtime_minutes " +
|
||||
"having sum(m.playtime_2weeks) > 0 order by sum(m.playtime_2weeks) desc, c.appid limit :limit",
|
||||
nativeQuery = true)
|
||||
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit);
|
||||
@@ -342,20 +370,11 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
* @param sources the sources one platform of the entry must have (a PlayStation title on a Steam row counts as
|
||||
* PSN); never empty, and ignored for the hidden games
|
||||
*/
|
||||
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||
"where ug.userId = :userId and ug.removedAt is null " +
|
||||
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
||||
"and (:status is null or ug.status = :status) " +
|
||||
"and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " +
|
||||
" or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " +
|
||||
" join SteamGame mg on mg.appid = p.memberAppid " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " +
|
||||
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
||||
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
|
||||
"and (:hidden = true or exists (select 1 from CountedUserGame c " +
|
||||
" where c.userId = ug.userId and c.appid = ug.appid)) " +
|
||||
"and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))")
|
||||
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
|
||||
// join it only for the playtime sort or the PLAYING status, with a second query.
|
||||
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE,
|
||||
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
|
||||
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
||||
@Param("hidden") boolean hidden,
|
||||
@Param("status") GameStatus status,
|
||||
@@ -386,13 +405,15 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
/**
|
||||
* 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.
|
||||
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total.
|
||||
*/
|
||||
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 "playtimeMinutes" -> Sort.by(JpaSort.unsafe(order.getDirection(), ENTRY_PLAYTIME_ORDER)
|
||||
.iterator().next());
|
||||
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());
|
||||
|
||||
@@ -60,11 +60,9 @@ public class UserStatsRepository {
|
||||
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid
|
||||
where %s and ug.status = 'BACKLOG' and %s
|
||||
""".formatted(OF_USER, UserGameRepository.NOT_COMING);
|
||||
/** Per entry (see V43), the playtime of all its editions; the user's other rows are not in it. */
|
||||
/** Per entry, the set total (V45); the user's other rows are not in it. */
|
||||
private static final String ENTRY_PLAYTIME = """
|
||||
(select e.appid, sum(m.playtime_minutes) as playtime_minutes from user_game_entry_member e
|
||||
join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid
|
||||
where e.user_id = :userId group by e.appid)""";
|
||||
(select ep.appid, ep.playtime_minutes from user_game_entry_playtime ep where ep.user_id = :userId)""";
|
||||
private static final String MOST_PLAYED_FROM = """
|
||||
from %s ug join steam_game sg on sg.appid = ug.appid
|
||||
where ug.playtime_minutes > 0
|
||||
@@ -300,12 +298,13 @@ public class UserStatsRepository {
|
||||
*/
|
||||
public List<UserStats.StatGame> games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) {
|
||||
return jdbc.query("""
|
||||
select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime,
|
||||
select s.appid, sg.name, sg.image_url, ug.status, coalesce(ep.playtime_minutes, ug.playtime_minutes, 0) as playtime,
|
||||
s.minutes, s.event_at, s.from_status, s.to_status,
|
||||
cg.appid is not null as coming, cg.release_date as coming_on
|
||||
from (%s) s left join steam_game sg on sg.appid = s.appid
|
||||
left join coming_game cg on cg.appid = s.appid
|
||||
left join user_game ug on ug.user_id = :userId and ug.appid = s.appid
|
||||
left join user_game_entry_playtime ep on ep.user_id = :userId and ep.appid = s.appid
|
||||
order by %s, s.appid limit :limit offset :offset
|
||||
""".formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)),
|
||||
gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset),
|
||||
|
||||
@@ -57,6 +57,7 @@ import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.JpaSort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -405,7 +406,8 @@ public class LibraryQueryService {
|
||||
? userGameRepository.countEveryStatus(userId) : null;
|
||||
List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
|
||||
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
|
||||
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))))
|
||||
PageRequest.of(0, PUBLIC_PLAYING, JpaSort.unsafe(Sort.Direction.DESC,
|
||||
UserGameRepository.ENTRY_PLAYTIME_ORDER).and(Sort.by("appid"))))
|
||||
: null;
|
||||
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
|
||||
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
||||
@@ -416,6 +418,7 @@ public class LibraryQueryService {
|
||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(shownAppids)
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
Map<Long, ComingGame> coming = comingOf(shownAppids);
|
||||
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(userId, shownAppids);
|
||||
List<AchievementProgress> started = summary ? AchievementProgress.started(progress(userId)) : null;
|
||||
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
|
||||
return new PublicLibraryDto(
|
||||
@@ -423,7 +426,7 @@ public class LibraryQueryService {
|
||||
summary ? userGameRepository.sumPlaytime(userId) : null,
|
||||
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
|
||||
sections.contains(ProfileSection.STATUS) ? byStatus : null,
|
||||
toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming),
|
||||
toPublicGames(playing, meta, coming, totals), toPublicGames(finished, meta, coming, totals),
|
||||
summary ? entries(userId, AchievementProgress.perfectAppids(started)) : null,
|
||||
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
|
||||
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
||||
@@ -433,15 +436,16 @@ public class LibraryQueryService {
|
||||
* @return null for a hidden section
|
||||
*/
|
||||
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta,
|
||||
Map<Long, ComingGame> coming) {
|
||||
Map<Long, ComingGame> coming,
|
||||
Map<Long, Integer> totals) {
|
||||
if (games == null) {
|
||||
return null;
|
||||
}
|
||||
return games.stream()
|
||||
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(),
|
||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
|
||||
totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes()), ug.getStatus(), ug.getRating(),
|
||||
ug.getPlatform(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null,
|
||||
coming.containsKey(ug.getAppid()),
|
||||
coming.containsKey(ug.getAppid()) ? coming.get(ug.getAppid()).getReleaseDate() : null))
|
||||
.toList();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- The set total of an entry (Gitea #36), the SQL form of PlatformPlay#total: a row's own minutes plus the minutes of
|
||||
-- the PlayStation titles on it; a PlayStation row with title play, or a manual row with a title of its platform, counts
|
||||
-- its titles only. Capped at the int range. Hidden and removed rows have no entry.
|
||||
-- A migration that changes a user_game column drops this view first (before user_game_entry_platform), then creates
|
||||
-- it again. No correlated subquery here: it made a library page cost 2 s.
|
||||
CREATE VIEW user_game_entry_playtime AS
|
||||
SELECT e.user_id, e.appid,
|
||||
CAST(LEAST(SUM(CASE WHEN m.source = 'PSN' AND t.minutes IS NOT NULL
|
||||
OR m.source = 'MANUAL' AND m.platform = ANY (t.platforms) THEN 0
|
||||
ELSE m.playtime_minutes END + COALESCE(t.minutes, 0)), 2147483647) AS INTEGER) AS playtime_minutes
|
||||
FROM user_game_entry_member e
|
||||
JOIN listed_user_game m ON m.user_id = e.user_id AND m.appid = e.member_appid
|
||||
LEFT JOIN (SELECT user_id, appid, SUM(playtime_minutes) AS minutes, ARRAY_AGG(platform) AS platforms
|
||||
FROM psn_title_play GROUP BY user_id, appid) t ON t.user_id = m.user_id AND t.appid = m.appid
|
||||
GROUP BY e.user_id, e.appid;
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user