Public Access
feat(library): show one entry per game in every list
Gitea #34 slice 2 of 3. Lists and stats count a set once. V43 views map an entry to its editions and platforms. Remove the hideDuplicates param; GameDto gets platforms[].
This commit is contained in:
1 parent
82d3b59165
commit
413381d404
23 files changed
+676
-87
No files matched your search
@@ -136,7 +136,6 @@ public class LibraryController {
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@RequestParam(defaultValue = "false") boolean hidden,
|
||||
@RequestParam(defaultValue = "false") boolean hideDuplicates,
|
||||
@RequestParam(required = false) List<GameSource> source
|
||||
) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
@@ -145,7 +144,7 @@ public class LibraryController {
|
||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||
int safePage = Math.max(0, page);
|
||||
Sort safeSort = parseSort(sort);
|
||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort));
|
||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort));
|
||||
return ResponseEntity.ok(PageDto.of(dtoPage));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
|
||||
@@ -17,6 +18,7 @@ import java.time.LocalDate;
|
||||
* @param editions how many games of the user are in this game's linked set, null below 2
|
||||
* @param coming the game is not released yet and nobody played it; it does not count on the pile
|
||||
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
|
||||
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
|
||||
*/
|
||||
public record GameDto(
|
||||
Long appid,
|
||||
@@ -36,5 +38,6 @@ public record GameDto(
|
||||
boolean hidden,
|
||||
Integer editions,
|
||||
boolean coming,
|
||||
LocalDate comingOn
|
||||
LocalDate comingOn,
|
||||
List<PlatformDto> platforms
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
|
||||
/**
|
||||
* @param platform e.g. "PS5"; null when the edition has none
|
||||
*/
|
||||
public record PlatformDto(GameSource source, String platform) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A platform a library entry is on, e.g. {@code PSN} + "PS5".
|
||||
*
|
||||
* @param platform null when the edition has none, e.g. a Steam game
|
||||
*/
|
||||
public record EntryPlatform(GameSource source, String platform) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A library entry played in the last two weeks; the playtimes add up every edition of the entry.
|
||||
*/
|
||||
public interface RecentEntry {
|
||||
|
||||
Long getAppid();
|
||||
|
||||
String getStatus();
|
||||
|
||||
Long getPlaytimeMinutes();
|
||||
|
||||
Long getPlaytime2Weeks();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
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;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* A platform of a library entry, per edition of the entry: read-only view, see V43. {@code platform} is empty when the
|
||||
* edition has none.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "user_game_entry_platform")
|
||||
@IdClass(UserGameEntryPlatform.Key.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UserGameEntryPlatform {
|
||||
|
||||
public record Key(Long userId, Long appid, Long memberAppid, GameSource source, String platform) implements Serializable {}
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Id
|
||||
@Column(name = "member_appid")
|
||||
private Long memberAppid;
|
||||
|
||||
@Id
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source")
|
||||
private GameSource source;
|
||||
|
||||
@Id
|
||||
@Column(name = "platform")
|
||||
private String platform;
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* What the users the caller follows played of the caller's backlog. Plain SQL: it reads the follow module's tables
|
||||
* so the privacy rules and the data come from one statement.
|
||||
* so the privacy rules and the data come from one statement. A friend's game matches the caller's entry by key or
|
||||
* through the global game_link set, so another edition of the same game counts.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -34,8 +35,11 @@ public class FriendPlayRepository {
|
||||
join public_user p on p.user_id = f.followed_user_id
|
||||
join account_name a on a.user_id = f.followed_user_id
|
||||
join counted_user_game fg on fg.user_id = f.followed_user_id
|
||||
join counted_user_game ug on ug.user_id = f.user_id and ug.appid = fg.appid
|
||||
left join game_link lf on lf.appid = fg.appid
|
||||
join counted_user_game ug on ug.user_id = f.user_id
|
||||
left join game_link lu on lu.appid = ug.appid
|
||||
where f.user_id = :userId
|
||||
and coalesce(lf.set_id::text, 'a' || fg.appid) = coalesce(lu.set_id::text, 'a' || ug.appid)
|
||||
and fg.status <> 'DROPPED'
|
||||
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
|
||||
and not exists (select 1 from deleted_user d where d.user_id = f.followed_user_id)
|
||||
|
||||
@@ -2,6 +2,7 @@ package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.CatalogGame;
|
||||
import core_service.modules.library.domain.Edition;
|
||||
import core_service.modules.library.domain.EntryPlatform;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.LinkPair;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -305,6 +307,34 @@ public class GameLinkRepository {
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return per given entry key, the platforms of its editions (see V43): Steam, PlayStation, then manual, by
|
||||
* platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform
|
||||
*/
|
||||
public Map<Long, List<EntryPlatform>> entryPlatforms(long userId, Collection<Long> appids) {
|
||||
Map<Long, List<EntryPlatform>> platforms = new HashMap<>();
|
||||
if (appids.isEmpty()) {
|
||||
return platforms;
|
||||
}
|
||||
jdbc.query("""
|
||||
select appid, source, platform
|
||||
from user_game_entry_platform p
|
||||
where user_id = :user and appid in (:appids)
|
||||
and not (source = 'PSN' and platform = '' and exists (
|
||||
select 1 from user_game_entry_platform n
|
||||
where n.user_id = p.user_id and n.appid = p.appid and n.platform <> ''
|
||||
and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid))))
|
||||
group by appid, source, platform
|
||||
order by appid, case source when 'STEAM' then 0 when 'PSN' then 1 else 2 end, platform
|
||||
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
|
||||
rs -> {
|
||||
String platform = rs.getString("platform");
|
||||
platforms.computeIfAbsent(rs.getLong("appid"), k -> new ArrayList<>()).add(new EntryPlatform(
|
||||
GameSource.valueOf(rs.getString("source")), platform.isEmpty() ? null : platform));
|
||||
});
|
||||
return platforms;
|
||||
}
|
||||
|
||||
private static MapSqlParameterSource keys(long appid, long otherAppid) {
|
||||
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.FinishedWithoutDate;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.Mood;
|
||||
import core_service.modules.library.domain.RecentEntry;
|
||||
import core_service.modules.library.domain.StatusCount;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
@@ -63,18 +64,33 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null")
|
||||
List<Long> findHiddenAppids(@Param("userId") Long userId);
|
||||
|
||||
@Query("select count(ug) from UserGame ug where " + LISTED)
|
||||
/**
|
||||
* The entries of the user's lists: a set counts once, see V43.
|
||||
*/
|
||||
@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)
|
||||
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 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);
|
||||
|
||||
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
|
||||
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, 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.
|
||||
*/
|
||||
@Query(value = "select c.appid as appid, c.status as status, sum(m.playtime_minutes) as playtimeMinutes, " +
|
||||
"sum(m.playtime_2weeks) as playtime2Weeks " +
|
||||
"from counted_user_game c " +
|
||||
"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 " +
|
||||
"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);
|
||||
|
||||
@Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0")
|
||||
long countListedNeverPlayed(@Param("userId") Long userId);
|
||||
@@ -316,32 +332,35 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per entry (see V43): a listed game is the counted edition of its set; a hidden game is a row of its own.
|
||||
*
|
||||
* @param hidden true for only the hidden games, false for only the listed ones
|
||||
* @param status null for every status
|
||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||
* @param namePattern a {@link #containsPattern} result, null for every name; a listed entry matches by the name of
|
||||
* any of its editions
|
||||
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||
* @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions
|
||||
* @param sources the sources a row or one of its listed editions in the user's set (see V42) must have; never empty
|
||||
* @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 lower(sg.name) like :namePattern escape '\\') " +
|
||||
"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 (:hideDuplicates = false or exists (select 1 from CountedUserGame c " +
|
||||
"and (:hidden = true or exists (select 1 from CountedUserGame c " +
|
||||
" where c.userId = ug.userId and c.appid = ug.appid)) " +
|
||||
"and (ug.source in :sources or exists (select 1 from UserGameSetMember l " +
|
||||
" join UserGameSetMember o on o.userId = l.userId and o.setId = l.setId and o.appid <> l.appid " +
|
||||
" join UserGame og on og.userId = ug.userId and og.appid = o.appid " +
|
||||
" where l.userId = ug.userId and l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))")
|
||||
"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))")
|
||||
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
||||
@Param("hidden") boolean hidden,
|
||||
@Param("status") GameStatus status,
|
||||
@Param("namePattern") String namePattern,
|
||||
@Param("maxMinutes") Integer maxMinutes,
|
||||
@Param("hideDuplicates") boolean hideDuplicates,
|
||||
@Param("sources") Collection<GameSource> sources,
|
||||
Pageable pageable);
|
||||
|
||||
|
||||
@@ -39,11 +39,16 @@ public class UserStatsRepository {
|
||||
where sc.user_id = :userId and %s
|
||||
group by sc.appid
|
||||
""".formatted(FINISH_CHANGE);
|
||||
private static final String PLAYTIME_DAYS_NOT_HIDDEN = """
|
||||
from user_game_playtime_day p
|
||||
private static final String DAY_NOT_HIDDEN = """
|
||||
where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id
|
||||
and ug.appid = p.appid and ug.hidden_at is not null)
|
||||
""";
|
||||
private static final String PLAYTIME_DAYS_NOT_HIDDEN = "from user_game_playtime_day p\n" + DAY_NOT_HIDDEN;
|
||||
/** The playtime days like above, with the entry (see V43) of each day; a removed game is its own entry. */
|
||||
private static final String ENTRY_DAYS = """
|
||||
from user_game_playtime_day p
|
||||
left join user_game_entry_member e on e.user_id = p.user_id and e.member_appid = p.appid
|
||||
""" + DAY_NOT_HIDDEN;
|
||||
|
||||
/** The columns of a game set a set has no value for; the list query reads the same columns from every set. */
|
||||
private static final String NO_MINUTES = "cast(null as bigint) as minutes";
|
||||
@@ -55,10 +60,15 @@ 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. */
|
||||
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)""";
|
||||
private static final String MOST_PLAYED_FROM = """
|
||||
from listed_user_game ug join steam_game sg on sg.appid = ug.appid
|
||||
where %s and ug.playtime_minutes > 0
|
||||
""".formatted(OF_USER);
|
||||
from %s ug join steam_game sg on sg.appid = ug.appid
|
||||
where ug.playtime_minutes > 0
|
||||
""".formatted(ENTRY_PLAYTIME);
|
||||
private static final String PLATFORM_FROM = """
|
||||
from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
|
||||
ug.playtime_minutes
|
||||
@@ -95,8 +105,8 @@ public class UserStatsRepository {
|
||||
|
||||
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
|
||||
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
|
||||
private static final String LONGEST_DAY_SET = "select p.appid, sum(p.minutes) as minutes, " + NO_EVENT + ", "
|
||||
+ NO_MOVE + " " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day = :arg group by p.appid";
|
||||
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, "
|
||||
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
|
||||
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
|
||||
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
|
||||
private static final String MOVES_SET = "select sc.appid, " + NO_MINUTES + ", sc.changed_at as event_at, "
|
||||
@@ -113,13 +123,14 @@ public class UserStatsRepository {
|
||||
Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)),
|
||||
Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)),
|
||||
Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET),
|
||||
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, " + NO_STAT + " " + MOST_PLAYED_FROM),
|
||||
Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE
|
||||
+ " " + MOST_PLAYED_FROM),
|
||||
Map.entry(StatKey.GENRE, tagSet("genres")),
|
||||
Map.entry(StatKey.MOOD, tagSet("moods")),
|
||||
Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM
|
||||
+ "and lower(g.name) = lower(trim(:arg))"),
|
||||
Map.entry(StatKey.PERFECT, "select ug.appid, " + NO_STAT + " from listed_user_game ug where " + OF_USER
|
||||
+ "and ug.appid in (:arg)"),
|
||||
Map.entry(StatKey.PERFECT, "select distinct e.appid, " + NO_STAT + " from user_game_entry_member e "
|
||||
+ "where e.user_id = :userId and e.member_appid in (:arg)"),
|
||||
Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET),
|
||||
Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET),
|
||||
Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)),
|
||||
@@ -127,11 +138,13 @@ public class UserStatsRepository {
|
||||
Map.entry(StatKey.TREND_MOVES, MOVES_SET));
|
||||
|
||||
/** The list order; a key not here goes by playtime. */
|
||||
private static final Map<StatKey, String> ORDERS = Map.of(
|
||||
StatKey.NEVER_STARTED, BY_NAME, StatKey.PILE, BY_NAME, StatKey.FINISHED_UNTRACKED, BY_NAME,
|
||||
StatKey.PILE_TIME_TO_BEAT, BY_MINUTES, StatKey.LONGEST_DAY, BY_MINUTES,
|
||||
StatKey.FINISHED_YEAR, BY_DATE, StatKey.TREND_ADDED, BY_DATE, StatKey.TREND_DONE, BY_DATE,
|
||||
StatKey.TREND_MOVES, BY_DATE);
|
||||
private static final Map<StatKey, String> ORDERS = Map.ofEntries(
|
||||
Map.entry(StatKey.NEVER_STARTED, BY_NAME), Map.entry(StatKey.PILE, BY_NAME),
|
||||
Map.entry(StatKey.FINISHED_UNTRACKED, BY_NAME), Map.entry(StatKey.PILE_TIME_TO_BEAT, BY_MINUTES),
|
||||
Map.entry(StatKey.LONGEST_DAY, BY_MINUTES), Map.entry(StatKey.MOST_PLAYED, BY_MINUTES),
|
||||
Map.entry(StatKey.GENRE, BY_MINUTES), Map.entry(StatKey.MOOD, BY_MINUTES),
|
||||
Map.entry(StatKey.FINISHED_YEAR, BY_DATE), Map.entry(StatKey.TREND_ADDED, BY_DATE),
|
||||
Map.entry(StatKey.TREND_DONE, BY_DATE), Map.entry(StatKey.TREND_MOVES, BY_DATE));
|
||||
|
||||
private static final RowMapper<UserStats.TagCount> TAG_COUNT =
|
||||
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
|
||||
@@ -175,17 +188,18 @@ public class UserStatsRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* The listed game with playtime in the most months; more minutes break a tie.
|
||||
* The listed entry with playtime in the most months, all editions of its set together; more minutes break a tie.
|
||||
*/
|
||||
public Optional<UserStats.LoyalGame> mostLoyal(long userId) {
|
||||
return jdbc.query("""
|
||||
select l.appid, sg.name, sg.image_url, l.months, l.minutes
|
||||
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes
|
||||
%s group by p.appid) l
|
||||
from (select coalesce(e.appid, p.appid) as appid, count(distinct date_trunc('month', p.day)) as months,
|
||||
sum(p.minutes) as minutes
|
||||
%s group by coalesce(e.appid, p.appid)) l
|
||||
join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid
|
||||
join steam_game sg on sg.appid = l.appid
|
||||
order by l.months desc, l.minutes desc, l.appid limit 1
|
||||
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId),
|
||||
""".formatted(ENTRY_DAYS), user(userId),
|
||||
(rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"),
|
||||
rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes")))
|
||||
.stream().findFirst();
|
||||
@@ -201,13 +215,14 @@ public class UserStatsRepository {
|
||||
|
||||
private static String tagFrom(String column) {
|
||||
return """
|
||||
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
|
||||
where %s""".formatted(column, OF_USER);
|
||||
from counted_user_game ug join %s t on t.appid = ug.appid
|
||||
join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
|
||||
where %s""".formatted(ENTRY_PLAYTIME, column, OF_USER);
|
||||
}
|
||||
|
||||
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
|
||||
return jdbc.query("""
|
||||
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
|
||||
select tag.name, count(*) as games, coalesce(sum(t.playtime_minutes), 0) as minutes
|
||||
%s
|
||||
group by tag.name order by minutes desc, games desc, tag.name limit :limit
|
||||
""".formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT);
|
||||
@@ -312,7 +327,8 @@ public class UserStatsRepository {
|
||||
}
|
||||
|
||||
private static String tagSet(String column) {
|
||||
return "select ug.appid, " + NO_STAT + " " + tagFrom(column) + " and tag.name = :arg";
|
||||
return "select ug.appid, t.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE + " " + tagFrom(column)
|
||||
+ " and tag.name = :arg";
|
||||
}
|
||||
|
||||
/** A game once, with its last change. */
|
||||
|
||||
@@ -9,12 +9,14 @@ import core_service.modules.library.api.dto.PickReasonDto;
|
||||
import core_service.modules.library.api.dto.PickedGameDto;
|
||||
import core_service.modules.library.api.dto.PickerCategoryDto;
|
||||
import core_service.modules.library.api.dto.PickerDto;
|
||||
import core_service.modules.library.api.dto.PlatformDto;
|
||||
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||
import core_service.modules.library.api.dto.RecentGameDto;
|
||||
import core_service.modules.library.api.dto.WishlistDto;
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
import core_service.modules.library.domain.ComingGame;
|
||||
import core_service.modules.library.domain.DayMinutes;
|
||||
import core_service.modules.library.domain.EntryPlatform;
|
||||
import core_service.modules.library.domain.FriendPlay;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
@@ -23,7 +25,9 @@ import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.Mood;
|
||||
import core_service.modules.library.domain.PickSource;
|
||||
import core_service.modules.library.domain.ProfileSection;
|
||||
import core_service.modules.library.domain.RecentEntry;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.domain.StatKey;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
@@ -40,6 +44,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.repo.UserStatsRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
import core_service.shared.security.Public;
|
||||
@@ -96,6 +101,7 @@ public class LibraryQueryService {
|
||||
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
private final UserStatsService userStatsService;
|
||||
private final UserStatsRepository userStatsRepository;
|
||||
private final MoodService moodService;
|
||||
private final ReleaseDateService releaseDateService;
|
||||
private final SpeedrunService speedrunService;
|
||||
@@ -116,12 +122,12 @@ public class LibraryQueryService {
|
||||
}
|
||||
|
||||
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
|
||||
Integer maxMinutes, boolean hideDuplicates, Collection<GameSource> sources,
|
||||
Integer maxMinutes, Collection<GameSource> sources,
|
||||
Pageable pageable) {
|
||||
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
||||
UserGameRepository.querySort(pageable.getSort()));
|
||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, hideDuplicates,
|
||||
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes,
|
||||
sources == null || sources.isEmpty() ? EnumSet.allOf(GameSource.class) : sources, stable);
|
||||
List<GameDto> games = toDtos(page.getContent());
|
||||
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||
@@ -281,6 +287,7 @@ public class LibraryQueryService {
|
||||
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
|
||||
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
||||
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
|
||||
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
|
||||
Map<Long, ComingGame> coming = comingOf(appids);
|
||||
Map<Long, AchievementProgress> trophies = AchievementProgress
|
||||
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
|
||||
@@ -288,7 +295,8 @@ public class LibraryQueryService {
|
||||
return games.stream()
|
||||
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
||||
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
|
||||
coming.get(ug.getAppid()), trophies.get(ug.getAppid())))
|
||||
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
|
||||
trophies.get(ug.getAppid())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -312,8 +320,8 @@ public class LibraryQueryService {
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
return new AchievementSummaryDto(
|
||||
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
|
||||
perGame.size(),
|
||||
AchievementProgress.perfectAppids(started).size(),
|
||||
entries(userId, perGame.stream().map(AchievementProgress::appid).toList()),
|
||||
entries(userId, AchievementProgress.perfectAppids(started)),
|
||||
averageCompletion(perGame),
|
||||
(int) games.stream().filter(g -> !g.known()).count(),
|
||||
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
|
||||
@@ -325,6 +333,11 @@ public class LibraryQueryService {
|
||||
.toList());
|
||||
}
|
||||
|
||||
// Library entries of the given games: an entry whose editions are all in the list counts once.
|
||||
private int entries(Long userId, List<Long> appids) {
|
||||
return appids.isEmpty() ? 0 : (int) userStatsRepository.countGames(userId, StatKey.PERFECT, appids, zone);
|
||||
}
|
||||
|
||||
// Steam achievements and PlayStation trophies; one entry per set.
|
||||
private List<AchievementProgress> progress(Long userId) {
|
||||
return Stream.concat(userGameRepository.findAchievementProgress(userId).stream(),
|
||||
@@ -364,16 +377,16 @@ public class LibraryQueryService {
|
||||
.toList();
|
||||
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
|
||||
|
||||
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES));
|
||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
|
||||
List<RecentEntry> recent = userGameRepository.findEntriesPlayedInTwoWeeks(userId, RECENT_GAMES);
|
||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(RecentEntry::getAppid).toList())
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
List<RecentGameDto> recentGames = recent.stream()
|
||||
.map(ug -> new RecentGameDto(
|
||||
ug.getAppid(),
|
||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||
ug.getPlaytimeMinutes(),
|
||||
ug.getPlaytime2Weeks(),
|
||||
ug.getStatus(),
|
||||
ug.getPlaytimeMinutes().intValue(),
|
||||
ug.getPlaytime2Weeks().intValue(),
|
||||
GameStatus.valueOf(ug.getStatus()),
|
||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
|
||||
.toList();
|
||||
return new ActivityDto(perDay, total, recentGames);
|
||||
@@ -410,7 +423,7 @@ public class LibraryQueryService {
|
||||
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
|
||||
sections.contains(ProfileSection.STATUS) ? byStatus : null,
|
||||
toPublicGames(playing, meta, coming), toPublicGames(finished, meta, coming),
|
||||
summary ? AchievementProgress.perfectAppids(started).size() : null,
|
||||
summary ? entries(userId, AchievementProgress.perfectAppids(started)) : null,
|
||||
summary ? averageCompletion(AchievementProgress.perGame(started)) : null,
|
||||
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
||||
}
|
||||
@@ -442,7 +455,8 @@ public class LibraryQueryService {
|
||||
|
||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
||||
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
|
||||
Integer editions, ComingGame coming, AchievementProgress trophies) {
|
||||
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
|
||||
AchievementProgress trophies) {
|
||||
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
|
||||
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
|
||||
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
||||
@@ -471,7 +485,8 @@ public class LibraryQueryService {
|
||||
ug.getHiddenAt() != null,
|
||||
editions,
|
||||
coming != null,
|
||||
coming != null ? coming.getReleaseDate() : null);
|
||||
coming != null ? coming.getReleaseDate() : null,
|
||||
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList());
|
||||
}
|
||||
|
||||
// The tile shows what counts for the game: its best set, Steam's on a tie.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- One entry per game across platforms (Gitea #34, slice 2). An entry is a counted_user_game row (V42); these two views
|
||||
-- map it to its editions, the one place that does. Built on counted_user_game and listed_user_game: a migration that
|
||||
-- changes a column of user_game drops these two views first (before counted_user_game), then creates them again.
|
||||
|
||||
-- Every counted row with itself and the other listed rows of its user_game_set.
|
||||
CREATE VIEW user_game_entry_member AS
|
||||
SELECT c.user_id, c.appid, c.appid AS member_appid
|
||||
FROM counted_user_game c
|
||||
UNION
|
||||
SELECT c.user_id, c.appid, o.appid
|
||||
FROM counted_user_game c
|
||||
JOIN user_game_set me ON me.user_id = c.user_id AND me.appid = c.appid
|
||||
JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid
|
||||
JOIN listed_user_game og ON og.user_id = o.user_id AND og.appid = o.appid;
|
||||
|
||||
-- The platforms of an entry, per member: its own source and platform, and a PlayStation row for each title the user
|
||||
-- has on that key as LINKED or STEAM_OWNED (the Steam + PlayStation one-row case). UNION drops the duplicates.
|
||||
CREATE VIEW user_game_entry_platform AS
|
||||
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
|
||||
FROM user_game_entry_member m
|
||||
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
|
||||
UNION
|
||||
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
|
||||
FROM user_game_entry_member m
|
||||
JOIN user_psn_title t ON t.user_id = m.user_id AND t.appid = m.member_appid AND t.state IN ('LINKED', 'STEAM_OWNED');
|
||||
@@ -279,13 +279,26 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void hideDuplicates_ListsOnlyTheEditionTheSetCounts() throws Exception {
|
||||
void games_ASetIsOneRowWithThePlatformsOfAllItsEditions() throws Exception {
|
||||
jdbc.update("update user_game set source = 'PSN', platform = 'PS5' where user_id = ? and appid = ?", USER_A, GAME_2);
|
||||
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));
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].editions").value(2))
|
||||
.andExpect(jsonPath("$.content[0].platforms", hasSize(2)))
|
||||
.andExpect(jsonPath("$.content[0].platforms[0].source").value("PSN"))
|
||||
.andExpect(jsonPath("$.content[0].platforms[0].platform").value("PS5"))
|
||||
.andExpect(jsonPath("$.content[0].platforms[1].source").value("MANUAL"))
|
||||
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
|
||||
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(as(get("/api/library/games?hideDuplicates=false"), USER_A, Role.USER)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(1));
|
||||
}
|
||||
|
||||
private void setSource(long appid, GameSource source) {
|
||||
@@ -301,10 +314,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
setSource(GAME_2, GameSource.PSN);
|
||||
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
|
||||
|
||||
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
|
||||
games("?source=PSN&hideDuplicates=true").andExpect(jsonPath("$.totalElements").value(1));
|
||||
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
|
||||
games("?source=STEAM").andExpect(jsonPath("$.totalElements").value(0));
|
||||
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(2));
|
||||
games("?source=MANUAL,STEAM").andExpect(jsonPath("$.totalElements").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -426,7 +438,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
|
||||
void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception {
|
||||
setSource(GAME_2, GameSource.PSN);
|
||||
gameEditionService.linkSameGames(null);
|
||||
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2));
|
||||
games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1));
|
||||
|
||||
splitAs(USER_A, GAME).andExpect(status().isNoContent());
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
|
||||
userAchievementMetaRepository.deleteAll();
|
||||
jdbc.update("delete from user_psn_trophy_set");
|
||||
jdbc.update("delete from psn_trophy_set");
|
||||
jdbc.update("delete from game_link");
|
||||
}
|
||||
|
||||
private void trophies(long appid, String setId, String platform, int total, int earned, boolean platinum) {
|
||||
@@ -146,6 +147,19 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
|
||||
.andExpect(jsonPath("$.closest[1].imageUrl").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void summary_TwoPerfectEditionsOfASetCountAsOnePerfectGame() throws Exception {
|
||||
game(10L, 10, 10, true);
|
||||
game(20L, 4, 4, true);
|
||||
game(30L, 4, 4, true);
|
||||
jdbc.update("insert into game_link (appid, set_id) values (10, 7001), (20, 7001)");
|
||||
|
||||
mockMvc.perform(get("/api/library/achievements/summary"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.startedGames").value(2))
|
||||
.andExpect(jsonPath("$.perfectGames").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void summary_TrophiesCount_APlatinumSetIsPerfectAndOneGameCountsOnce() throws Exception {
|
||||
game(10L, null, null, true);
|
||||
|
||||
@@ -53,6 +53,7 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
jdbc.update("delete from user_game_playtime_day");
|
||||
jdbc.update("delete from game_link where set_id = 980001");
|
||||
SteamGame repairedPortal = new SteamGame(620L, "Portal 2", null, REPAIRED_COVER, Instant.now());
|
||||
steamGameRepository.saveAll(List.of(
|
||||
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||
@@ -90,6 +91,19 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
|
||||
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
|
||||
jdbc.update("insert into game_link (appid, set_id) values (220, 980001), (700, 980001)");
|
||||
|
||||
mockMvc.perform(get("/api/library/activity").param("days", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.recentGames", hasSize(2)))
|
||||
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
|
||||
.andExpect(jsonPath("$.recentGames[1].appid").value(220))
|
||||
.andExpect(jsonPath("$.recentGames[1].playtime2Weeks").value(80));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
|
||||
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
|
||||
|
||||
+12
@@ -76,6 +76,7 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
|
||||
jdbc.update("delete from account_name where user_id = ?", id);
|
||||
}
|
||||
jdbc.update("delete from follow where user_id = ?", USER_ID);
|
||||
jdbc.update("delete from game_link where set_id = 960001");
|
||||
gameReleaseRepository.deleteAll();
|
||||
achievementMetaRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
@@ -124,6 +125,17 @@ class LibraryControllerPickerFriendsTest extends AbstractMockedLibraryServicesTe
|
||||
return ROW + ".games[?(@.appid == " + appid + ")].reason." + field;
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_ThePlayStationEditionOfAFriendMatchesTheSteamEditionOfTheCaller() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
friend(MAX, "Max", null);
|
||||
friendGame(MAX, -10L, GameStatus.FINISHED, 100);
|
||||
jdbc.update("insert into game_link (appid, set_id) values (10, 960001), (-10, 960001)");
|
||||
|
||||
pick().andExpect(jsonPath(ROW + ".games[*].appid", contains(10)))
|
||||
.andExpect(jsonPath(reason(10, "friendName"), contains("Max")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void friends_FinishedAndPlayedGames_WithReason() throws Exception {
|
||||
mine(10L, GameStatus.BACKLOG, null);
|
||||
|
||||
@@ -93,6 +93,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from game_link where set_id = 980002");
|
||||
gameReleaseRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
schemaMetaRepository.deleteAll();
|
||||
@@ -114,6 +115,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
new UserGame(99L, 220L, 5000, Instant.now(), Instant.now())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicLibrary_ListsASetOnce() throws Exception {
|
||||
jdbc.update("insert into game_link (appid, set_id) values (220, 980002), (700, 980002)");
|
||||
|
||||
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalGames").value(2))
|
||||
.andExpect(jsonPath("$.playing", hasSize(0)))
|
||||
.andExpect(jsonPath("$.finished", hasSize(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
|
||||
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
|
||||
|
||||
@@ -92,9 +92,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
|
||||
count("PILE", null, "/pile/backlogGames"),
|
||||
new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes",
|
||||
s -> s.at("/pile/normalMinutes").asLong()),
|
||||
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes",
|
||||
new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "minutes",
|
||||
s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")),
|
||||
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes",
|
||||
new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "minutes",
|
||||
s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")),
|
||||
new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()),
|
||||
count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GameEntryViewTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final long USER = 1L;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from user_psn_title");
|
||||
jdbc.update("delete from game_link");
|
||||
jdbc.update("delete from user_game");
|
||||
for (long appid : List.of(-10L, 10L, 20L, 30L)) {
|
||||
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
|
||||
}
|
||||
}
|
||||
|
||||
private void set(long appid, long setId) {
|
||||
jdbc.update("insert into game_link (appid, set_id) values (?, ?)", appid, setId);
|
||||
}
|
||||
|
||||
private void own(long appid, String source, String platform, int playtime) {
|
||||
jdbc.update("insert into user_game (user_id, appid, source, platform, playtime_minutes, status) values (?, ?, ?, ?, ?, 'BACKLOG')",
|
||||
USER, appid, source, platform, playtime);
|
||||
}
|
||||
|
||||
private void title(String key, String state, long appid) {
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, ?, ?, ?)", USER, key, state, appid);
|
||||
}
|
||||
|
||||
private List<String> members() {
|
||||
return jdbc.queryForList("select appid || '>' || member_appid from user_game_entry_member where user_id = ? "
|
||||
+ "order by appid, member_appid", String.class, USER);
|
||||
}
|
||||
|
||||
private List<String> platforms(long appid) {
|
||||
return jdbc.queryForList("select member_appid || ':' || source || ':' || platform from user_game_entry_platform "
|
||||
+ "where user_id = ? and appid = ? order by member_appid, source", String.class, USER, appid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void member_EntryHasItselfAndItsListedEditions() {
|
||||
own(10, "STEAM", null, 600);
|
||||
own(-10, "PSN", "PS5", 60);
|
||||
own(20, "STEAM", null, 5);
|
||||
set(10, 1);
|
||||
set(-10, 1);
|
||||
|
||||
assertThat(members()).containsExactly("10>-10", "10>10", "20>20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void member_LeavesOutAHiddenEdition() {
|
||||
own(10, "STEAM", null, 600);
|
||||
own(-10, "PSN", "PS5", 60);
|
||||
set(10, 1);
|
||||
set(-10, 1);
|
||||
jdbc.update("update user_game set hidden_at = now() where appid = -10");
|
||||
|
||||
assertThat(members()).containsExactly("10>10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void member_ASplitEditionIsItsOwnEntry() {
|
||||
own(10, "STEAM", null, 600);
|
||||
own(-10, "PSN", "PS5", 60);
|
||||
set(10, 1);
|
||||
set(-10, 1);
|
||||
jdbc.update("insert into user_game_split (user_id, appid) values (?, -10)", USER);
|
||||
|
||||
assertThat(members()).containsExactly("-10>-10", "10>10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platform_ASteamOwnedPsnTitleGivesAPsnPlatformOnTheSteamRow() {
|
||||
own(10, "STEAM", null, 600);
|
||||
title("c1", "STEAM_OWNED", 10);
|
||||
|
||||
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platform_TwoTitlesOnOneKeyGiveOnePsnRow() {
|
||||
own(10, "STEAM", null, 600);
|
||||
title("c1", "STEAM_OWNED", 10);
|
||||
title("c2", "STEAM_OWNED", 10);
|
||||
|
||||
assertThat(platforms(10)).containsExactly("10:PSN:", "10:STEAM:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platform_ASetListsEveryEditionWithItsPlatform() {
|
||||
own(10, "STEAM", null, 600);
|
||||
own(-10, "PSN", "PS5", 60);
|
||||
set(10, 1);
|
||||
set(-10, 1);
|
||||
|
||||
assertThat(platforms(10)).containsExactly("-10:PSN:PS5", "10:STEAM:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platform_ADismissedTitleGivesNoPlatform() {
|
||||
own(10, "STEAM", null, 600);
|
||||
title("c1", "DISMISSED", 10);
|
||||
|
||||
assertThat(platforms(10)).containsExactly("10:STEAM:");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
|
||||
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "user_game")) {
|
||||
jdbc.update("delete from " + table);
|
||||
}
|
||||
for (long appid : List.of(100L, 200L, -7L, -8L, 300L)) {
|
||||
@@ -254,6 +254,47 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.linkedEditions(USER, 100)).isEmpty();
|
||||
}
|
||||
|
||||
private void ownOn(long appid, String source, String platform) {
|
||||
jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)",
|
||||
USER, appid, source, platform);
|
||||
}
|
||||
|
||||
@Test
|
||||
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
|
||||
repository.link(100, 200);
|
||||
repository.link(100, -7);
|
||||
ownOn(100, "STEAM", null);
|
||||
ownOn(200, "PSN", "PS5");
|
||||
ownOn(-7, "MANUAL", "Switch");
|
||||
jdbc.update("update user_game set playtime_minutes = 500 where appid = 100");
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
|
||||
|
||||
var platforms = repository.entryPlatforms(USER, List.of(100L, 300L));
|
||||
|
||||
assertThat(platforms.keySet()).containsExactly(100L);
|
||||
assertThat(platforms.get(100L)).extracting(p -> p.source() + ":" + p.platform())
|
||||
.containsExactly("STEAM:null", "PSN:PS5", "MANUAL:Switch");
|
||||
assertThat(repository.entryPlatforms(USER, List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void entryPlatforms_ASteamRowWithAPlayStationTitleHasBothPlatforms() {
|
||||
ownOn(100, "STEAM", null);
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER);
|
||||
|
||||
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L))
|
||||
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "PSN:null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void entryPlatforms_AManualPlayStationRowWithALinkedTitleIsOnePlatform() {
|
||||
ownOn(-7, "MANUAL", "PS5");
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', -7)", USER);
|
||||
|
||||
assertThat(repository.entryPlatforms(USER, List.of(-7L)).get(-7L))
|
||||
.extracting(p -> p.source() + ":" + p.platform()).containsExactly("MANUAL:PS5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() {
|
||||
repository.link(100, 200);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.RecentEntry;
|
||||
import core_service.modules.library.domain.StatusCount;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -81,7 +82,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private List<Long> appids(GameStatus status, String query) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, false, ALL_SOURCES, PAGE)
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, ALL_SOURCES, PAGE)
|
||||
.map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@@ -210,7 +211,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
||||
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, false, ALL_SOURCES,
|
||||
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null, ALL_SOURCES,
|
||||
PageRequest.of(0, 1, Sort.by("appid")));
|
||||
|
||||
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
||||
@@ -218,7 +219,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private List<Long> sortedAppids(Sort sort) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||
.map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@@ -269,7 +270,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, false, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
|
||||
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent();
|
||||
|
||||
assertEquals(List.of(620L), shortGames);
|
||||
}
|
||||
@@ -296,7 +297,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, false, ALL_SOURCES,
|
||||
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null, ALL_SOURCES,
|
||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
|
||||
}
|
||||
@@ -317,8 +318,8 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
|
||||
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
|
||||
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
|
||||
.stream().map(UserGame::getAppid).toList());
|
||||
assertEquals(List.of(220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
|
||||
.stream().map(RecentEntry::getAppid).toList());
|
||||
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
|
||||
.stream().map(UserGame::getAppid).toList());
|
||||
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
|
||||
@@ -370,7 +371,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private List<Long> appidsOfSources(GameSource... sources) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, false, Set.of(sources),
|
||||
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, Set.of(sources),
|
||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@@ -421,19 +422,99 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkedBacklogEditions_CountOnceAndTheOnceFilterKeepsOne() {
|
||||
void linkedBacklogEditions_CountOnceAndTheListKeepsOne() {
|
||||
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, ALL_SOURCES,
|
||||
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, ALL_SOURCES,
|
||||
assertEquals(List.of(220L, 380L, 620L), userGameRepository.findOwnedGames(USER_ID, false, null, null, null, ALL_SOURCES,
|
||||
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||
}
|
||||
|
||||
private List<Long> listed(boolean hidden, String query) {
|
||||
return userGameRepository.findOwnedGames(USER_ID, hidden, null, UserGameRepository.containsPattern(query), null,
|
||||
ALL_SOURCES, PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_ASetIsOneRow_TheCountedEdition() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.flush();
|
||||
|
||||
assertEquals(List.of(220L, 620L, 700L), listed(false, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_TheNameOfTheLesserEditionFindsTheEntry() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.flush();
|
||||
|
||||
assertEquals(List.of(220L), listed(false, "episode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_BySource_FindsASteamRowWithAPlayStationTitle() {
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 620)", USER_ID);
|
||||
|
||||
assertEquals(List.of(620L), appidsOfSources(GameSource.PSN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_BySource_FindsAnEntryThroughItsLesserEdition() {
|
||||
link(220L, 380L);
|
||||
setSource(GameSource.PSN, 380L);
|
||||
|
||||
assertEquals(List.of(220L), appidsOfSources(GameSource.PSN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_Hidden_ListsEveryHiddenRow() {
|
||||
link(220L, 380L);
|
||||
hide(220L, 380L);
|
||||
|
||||
assertEquals(List.of(220L, 380L), listed(true, null));
|
||||
assertEquals(List.of(380L), listed(true, "episode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findEntriesPlayedInTwoWeeks_SumsTheSetAndKeepsAnEntryPlayedOnlyOnTheLesserEdition() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setPlaytime2Weeks(40);
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(50);
|
||||
userGameRepository.flush();
|
||||
|
||||
var recent = userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20);
|
||||
|
||||
assertEquals(List.of(220L, 620L), recent.stream().map(RecentEntry::getAppid).toList());
|
||||
assertEquals(70L, recent.getFirst().getPlaytime2Weeks());
|
||||
assertEquals(300L, recent.getFirst().getPlaytimeMinutes());
|
||||
|
||||
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(0);
|
||||
userGameRepository.flush();
|
||||
assertEquals(List.of(620L, 220L), userGameRepository.findEntriesPlayedInTwoWeeks(USER_ID, 20)
|
||||
.stream().map(RecentEntry::getAppid).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countListed_CountsASetOnce() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.flush();
|
||||
|
||||
assertEquals(3, userGameRepository.countListed(USER_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findListedByStatusIn_ListsASetOnce() {
|
||||
link(220L, 380L);
|
||||
userGameRepository.flush();
|
||||
|
||||
assertEquals(List.of(220L, 620L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
|
||||
.stream().map(UserGame::getAppid).sorted().toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backlogEditionOfAPlayingSet_IsNeverPicked() {
|
||||
link(220L, 380L);
|
||||
|
||||
@@ -374,6 +374,81 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
private void seedSet() {
|
||||
userGameRepository.saveAllAndFlush(List.of(
|
||||
game(220L, 300, GameStatus.PLAYING, Instant.now()),
|
||||
game(620L, 900, GameStatus.PLAYING, Instant.now()),
|
||||
game(700L, 500, GameStatus.PLAYING, Instant.now())));
|
||||
link(220L, 620L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mostPlayed_ASetIsOneRowWithTheSumOfItsEditions() {
|
||||
seedSet();
|
||||
|
||||
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::minutes)
|
||||
.containsExactly(1200L, 500L);
|
||||
assertThat(repository.games(USER_ID, StatKey.MOST_PLAYED, "", BERLIN, 0, 10))
|
||||
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mostLoyal_CountsTheMonthsOfEveryEditionOfASet() {
|
||||
seedSet();
|
||||
addDay(220L, LocalDate.of(2026, 7, 1), 10);
|
||||
addDay(620L, LocalDate.of(2026, 8, 1), 10);
|
||||
addDay(620L, LocalDate.of(2026, 9, 1), 10);
|
||||
addDay(700L, LocalDate.of(2026, 7, 3), 30);
|
||||
addDay(700L, LocalDate.of(2026, 8, 3), 30);
|
||||
|
||||
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
|
||||
|
||||
assertThat(loyal.months()).isEqualTo(3);
|
||||
assertThat(loyal.minutes()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
void genresAndMoods_CountASetOnceWithTheSummedMinutes() {
|
||||
seedSet();
|
||||
for (long appid : List.of(220L, 620L, 700L)) {
|
||||
gameMoodsRepository.upsert(appid, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
|
||||
}
|
||||
|
||||
assertThat(repository.genres(USER_ID, 8)).containsExactly(new UserStats.TagCount("Shooter", 2, 1700));
|
||||
assertThat(repository.moods(USER_ID)).containsExactly(new UserStats.TagCount("ACTION", 2, 1700));
|
||||
assertThat(repository.countGames(USER_ID, StatKey.GENRE, "Shooter", BERLIN)).isEqualTo(2);
|
||||
assertThat(repository.games(USER_ID, StatKey.GENRE, "Shooter", BERLIN, 0, 10))
|
||||
.extracting(UserStats.StatGame::minutes).containsExactly(1200L, 500L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void games_PerfectListsASetOnce() {
|
||||
seedSet();
|
||||
|
||||
assertThat(repository.countGames(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN)).isEqualTo(1);
|
||||
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
|
||||
seedSet();
|
||||
LocalDate day = LocalDate.of(2026, 7, 1);
|
||||
addDay(220L, day, 100);
|
||||
addDay(620L, day, 100);
|
||||
|
||||
assertThat(repository.countGames(USER_ID, StatKey.LONGEST_DAY, day, BERLIN)).isEqualTo(1);
|
||||
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
|
||||
.extracting(UserStats.StatGame::minutes).containsExactly(200L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformStat_ASetCountsOnEveryPlatformItHas() {
|
||||
seedSet();
|
||||
|
||||
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(new UserStats.TagCount("Steam", 3, 1700));
|
||||
assertThat(repository.games(USER_ID, StatKey.PLATFORM, "Steam", BERLIN, 0, 10)).hasSize(3);
|
||||
}
|
||||
|
||||
private void comingTomorrow(long appid) {
|
||||
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (?, ?, now())", appid,
|
||||
Date.valueOf(LocalDate.now(BERLIN).plusDays(1)));
|
||||
|
||||
Reference in new issue
Block a user