diff --git a/readme.md b/readme.md index 4ee355a..8eab632 100644 --- a/readme.md +++ b/readme.md @@ -116,8 +116,10 @@ flowchart LR status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view `listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then - playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime - sums, most played, genres, moods and platforms still use every listed game. + playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal, + genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions + (`user_game_entry_member`, V43; its list shows that sum as `minutes`). Platforms and playtime sums (`sumPlaytime`) stay + per edition: a Steam + PlayStation set counts on both platforms. - **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page) or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor @@ -164,7 +166,7 @@ flowchart LR sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles, public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`. - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games - and average completion (the numbers count a linked set once; the lists keep every edition). Readable only + and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by default. A section the user @@ -216,7 +218,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | -| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set; `source=STEAM,PSN` keeps rows whose own source or a listed linked edition's source is selected; ignored with `hidden=true`; `400` on an unknown source), `/api/library/games/{appid}` | JWT | +| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual; `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | @@ -339,7 +341,7 @@ flowchart LR `user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`. **One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in -SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` +SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads @@ -347,7 +349,10 @@ reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_us The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes -a column of `user_game` drops this view first, then `listed_user_game`. +a column of `user_game` drops the views built on it first: `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`. +The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member` +is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source +and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key. Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: @@ -550,11 +555,17 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. - `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. -- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library - filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the +- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the + library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their + drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The + friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on + purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry), `sumPlaytime` and the + community stats of the landing page. Card playtime is the counted edition's own; the set total comes with the + detail page. Pile Points pay a set once (see "Pile Points"), and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once. - The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions` - (size of the set in the user's library, `null` below 2). + (size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for + the icons of the card). ```mermaid flowchart LR @@ -563,6 +574,10 @@ flowchart LR Link --> Set["user_game_set"] Split["user_game_split (user splits)"] -->|"minus"| Set Set --> Counted["counted_user_game"] + Counted --> Member["user_game_entry_member"] + Member --> Platform["user_game_entry_platform"] + Platform -->|"platforms[]"| Card["library card, lists"] + Member -->|"summed playtime"| Card Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"] Row -->|"update: user_game_set_share"| Row Row -.->|"old values"| Backup["user_game_set_backup"] diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 1afe7c6..f3e7237 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 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)); } diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index 4457dea..ddb465a 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -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 platforms ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PlatformDto.java b/src/main/java/core_service/modules/library/api/dto/PlatformDto.java new file mode 100644 index 0000000..2479b68 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/PlatformDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/EntryPlatform.java b/src/main/java/core_service/modules/library/domain/EntryPlatform.java new file mode 100644 index 0000000..d1248b7 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/EntryPlatform.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/RecentEntry.java b/src/main/java/core_service/modules/library/domain/RecentEntry.java new file mode 100644 index 0000000..08b05a8 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/RecentEntry.java @@ -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(); +} diff --git a/src/main/java/core_service/modules/library/domain/UserGameEntryPlatform.java b/src/main/java/core_service/modules/library/domain/UserGameEntryPlatform.java new file mode 100644 index 0000000..11b3cb5 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserGameEntryPlatform.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java b/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java index 4562d6e..3bc993c 100644 --- a/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java +++ b/src/main/java/core_service/modules/library/repo/FriendPlayRepository.java @@ -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) diff --git a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java index 3fd1766..bd0e4a7 100644 --- a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -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> entryPlatforms(long userId, Collection appids) { + Map> 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); } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 6316f30..0e74f0e 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -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 @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null") List 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 findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection statuses, Pageable pageable); - @Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc") - List 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 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 } /** + * 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 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 sources, Pageable pageable); diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 5611303..4466d52 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -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 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 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 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 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 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. */ diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index f7b69f3..e17d450 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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 listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery, - Integer maxMinutes, boolean hideDuplicates, Collection sources, + Integer maxMinutes, Collection sources, Pageable pageable) { Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), UserGameRepository.querySort(pageable.getSort())); Page 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 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 editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids); + Map> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids); Map coming = comingOf(appids); Map 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 appids) { + return appids.isEmpty() ? 0 : (int) userStatsRepository.countGames(userId, StatKey.PERFECT, appids, zone); + } + // Steam achievements and PlayStation trophies; one entry per set. private List 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 recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES)); - Map meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList()) + List recent = userGameRepository.findEntriesPlayedInTwoWeeks(userId, RECENT_GAMES); + Map meta = steamGameRepository.findAllById(recent.stream().map(RecentEntry::getAppid).toList()) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); List 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 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. diff --git a/src/main/resources/db/migration/V43__Game_Entries.sql b/src/main/resources/db/migration/V43__Game_Entries.sql new file mode 100644 index 0000000..585bc57 --- /dev/null +++ b/src/main/resources/db/migration/V43__Game_Entries.sql @@ -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'); diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java index 5b99985..76838a5 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java index eb88feb..0fe0b16 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java index 015101f..c7872d4 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java @@ -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))); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java index 49149bb..38a4aeb 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerFriendsTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 8d6e404..90586d3 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -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))) diff --git a/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java index 61fb66f..37591c1 100644 --- a/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java +++ b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java @@ -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"), diff --git a/src/test/java/core_service/modules/library/repo/GameEntryViewTest.java b/src/test/java/core_service/modules/library/repo/GameEntryViewTest.java new file mode 100644 index 0000000..c3cec28 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameEntryViewTest.java @@ -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 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 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:"); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java index 9088f83..da01b78 100644 --- a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 2634527..35df79c 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -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 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 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 shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, false, ALL_SOURCES, PAGE).map(UserGame::getAppid).getContent(); + List 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 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 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); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index f6673bf..a659d66 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -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)));