Public Access
feat(library): add hand-ordered backlog with next-up picker source
This commit is contained in:
1 parent
7b538b7f30
commit
7747bc7b8b
22 files changed
+754
-20
No files matched your search
@@ -21,13 +21,20 @@ To Be Played is split into three repositories:
|
||||
can be taken over; its own finish then changes nothing.
|
||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from three
|
||||
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from four
|
||||
sources, one per slot in turn: a game sharing moods with a random one of the user's 5 most played games (the
|
||||
most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first,
|
||||
then MEDIUM, then the rest). A source with nothing left gives its slot to a random game; no game appears twice.
|
||||
then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the
|
||||
budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice.
|
||||
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
|
||||
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
|
||||
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
|
||||
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
|
||||
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
|
||||
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
|
||||
date added). `PUT /api/library/backlog` replaces the whole order under a row lock; ids not in the backlog are
|
||||
ignored and games not sent go after the sent ones. A merge keeps the better position of the two games. Coming
|
||||
games are in the list; `released=true` leaves them out.
|
||||
- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or
|
||||
achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat
|
||||
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
|
||||
@@ -37,9 +44,10 @@ To Be Played is split into three repositories:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["anchor: random of top 5 played"] --> S[similar]
|
||||
S --> M["PickMix: rotate, dedupe, random fallback"]
|
||||
S --> M["PickMix: rotate 4 sources, dedupe, random fallback"]
|
||||
L[longest waiting] --> M
|
||||
R[random] --> M
|
||||
N["next up: random of top 5 in order"] --> M
|
||||
M --> O["games + reason"]
|
||||
```
|
||||
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and
|
||||
@@ -186,6 +194,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
|
||||
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
|
||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` (each game has a `reason` object) | JWT |
|
||||
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||
|
||||
@@ -21,7 +21,7 @@ public class AccountCleanupRepository {
|
||||
|
||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
|
||||
"account_role_change", "blocked_user", "deleted_user");
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package core_service.modules.library.api;
|
||||
import core_service.shared.api.ErrorResponse;
|
||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||
import core_service.modules.library.api.dto.ActivityDto;
|
||||
import core_service.modules.library.api.dto.BacklogOrderDto;
|
||||
import core_service.modules.library.api.dto.BacklogOrderRequest;
|
||||
import core_service.modules.library.api.dto.BacklogPatch;
|
||||
import core_service.modules.library.api.dto.AuditEntryDto;
|
||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||
@@ -96,6 +98,7 @@ public class LibraryController {
|
||||
private static final int MAX_ACTIVITY_DAYS = 90;
|
||||
private static final int MAX_PICKER_GAMES = 10;
|
||||
private static final int MAX_PICKER_EXCLUDES = 100;
|
||||
private static final int MAX_BACKLOG_GAMES = 20;
|
||||
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
||||
|
||||
private final LibrarySyncService syncService;
|
||||
@@ -465,6 +468,21 @@ public class LibraryController {
|
||||
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
|
||||
}
|
||||
|
||||
@GetMapping("/backlog")
|
||||
public ResponseEntity<BacklogOrderDto> getBacklog(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) Integer limit,
|
||||
@RequestParam(defaultValue = "false") boolean released) {
|
||||
Integer safeLimit = limit == null ? null : Math.clamp(limit, 1, MAX_BACKLOG_GAMES);
|
||||
return ResponseEntity.ok(queryService.listBacklog(principal.getIdAsLong(), released, safeLimit));
|
||||
}
|
||||
|
||||
@PutMapping("/backlog")
|
||||
public ResponseEntity<Void> putBacklogOrder(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody BacklogOrderRequest request) {
|
||||
backlogService.reorder(principal.getIdAsLong(), request.appids());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/picker")
|
||||
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record BacklogOrderDto(List<GameDto> games) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @param appids the backlog in the wanted order
|
||||
*/
|
||||
public record BacklogOrderRequest(@NotNull @Size(max = 20000) List<@NotNull Long> appids) {}
|
||||
@@ -25,4 +25,8 @@ public record PickReasonDto(PickSource source, Long anchorAppid, String anchorNa
|
||||
public static PickReasonDto random() {
|
||||
return new PickReasonDto(PickSource.RANDOM, null, null, null, null);
|
||||
}
|
||||
|
||||
public static PickReasonDto nextUp() {
|
||||
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The user's own order of the backlog.
|
||||
*/
|
||||
public final class BacklogOrder {
|
||||
|
||||
private BacklogOrder() {}
|
||||
|
||||
/**
|
||||
* @return the wanted appids that are in current, in wanted order, each once; then the rest of current in its order
|
||||
*/
|
||||
public static List<Long> reorder(List<Long> current, List<Long> wanted) {
|
||||
Set<Long> ordered = new LinkedHashSet<>(wanted);
|
||||
ordered.retainAll(new HashSet<>(current));
|
||||
ordered.addAll(current);
|
||||
return new ArrayList<>(ordered);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Mixes the picker's three candidate lists into one list of picks.
|
||||
* Mixes the picker's four candidate lists into one list of picks.
|
||||
*/
|
||||
public final class PickMix {
|
||||
|
||||
@@ -15,13 +15,15 @@ public final class PickMix {
|
||||
private PickMix() {}
|
||||
|
||||
/**
|
||||
* Slot i takes the first unused game of source i modulo 3; when that source has none, the first unused random game.
|
||||
* Slot i takes the first unused game of source i modulo the number of sources; when that source has none, the
|
||||
* first unused random game.
|
||||
* Stops early when no game is left.
|
||||
*
|
||||
* @param count the most picks
|
||||
*/
|
||||
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random, int count) {
|
||||
List<List<UserGame>> bySource = List.of(similar, longest, random);
|
||||
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random,
|
||||
List<UserGame> nextUp, int count) {
|
||||
List<List<UserGame>> bySource = List.of(similar, longest, random, nextUp);
|
||||
Set<Long> used = new HashSet<>();
|
||||
List<Pick> picks = new ArrayList<>();
|
||||
PickSource[] sources = PickSource.values();
|
||||
|
||||
@@ -6,5 +6,6 @@ package core_service.modules.library.domain;
|
||||
public enum PickSource {
|
||||
SIMILAR,
|
||||
LONGEST,
|
||||
RANDOM
|
||||
RANDOM,
|
||||
NEXT_UP
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The user's backlog order. Plain SQL: the rows are written by a trigger on user_game (V34), not by an entity.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class BacklogOrderRepository {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
/**
|
||||
* Locks the user's order rows until the transaction ends, so two reorders do not cross.
|
||||
*
|
||||
* @return the appids in their current order
|
||||
*/
|
||||
public List<Long> lockOrder(long userId) {
|
||||
return jdbc.query("select appid from backlog_order where user_id = :userId order by position, appid for update",
|
||||
new MapSqlParameterSource("userId", userId), (rs, row) -> rs.getLong("appid"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the appids the positions 1..n in the given order.
|
||||
*/
|
||||
public void writeOrder(long userId, List<Long> appids) {
|
||||
List<SqlParameterSource> batch = new ArrayList<>();
|
||||
for (int i = 0; i < appids.size(); i++) {
|
||||
batch.add(new MapSqlParameterSource("userId", userId).addValue("appid", appids.get(i))
|
||||
.addValue("position", i + 1));
|
||||
}
|
||||
jdbc.batchUpdate("update backlog_order set position = :position where user_id = :userId and appid = :appid",
|
||||
batch.toArray(new SqlParameterSource[0]));
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,9 @@ public class GameMergeRepository {
|
||||
jdbc.update("delete from user_game_status_change where appid = :to and changed_at = now()", keys);
|
||||
jdbc.update("update user_game_status_change c set appid = :to where c.appid = :from and exists "
|
||||
+ "(select 1 from user_game t where t.user_id = c.user_id and t.appid = :to)", keys);
|
||||
// The kept game takes the better backlog position of the two; the from row's goes with the row (FK cascade).
|
||||
jdbc.update("update backlog_order t set position = least(t.position, f.position) from backlog_order f "
|
||||
+ "where f.user_id = t.user_id and f.appid = :from and t.appid = :to", keys);
|
||||
jdbc.update("delete from user_game f where f.appid = :from and exists "
|
||||
+ "(select 1 from user_game t where t.user_id = f.user_id and t.appid = :to)", keys);
|
||||
int moved = jdbc.update("update user_game set appid = :to where appid = :from", keys);
|
||||
|
||||
@@ -140,6 +140,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
" where s.appid = ug.appid and s.weblink is not null)) " +
|
||||
"and " + NOT_COMING;
|
||||
|
||||
String IN_BACKLOG_ORDER = "counted_user_game ug join backlog_order bo on bo.user_id = ug.user_id " +
|
||||
"and bo.appid = ug.appid ";
|
||||
|
||||
String PRIORITY_ORDER = "case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end";
|
||||
|
||||
/**
|
||||
@@ -182,6 +185,38 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||
@Param("count") int count);
|
||||
|
||||
/**
|
||||
* The listed backlog in the user's own order.
|
||||
*
|
||||
* @param released true leaves out the coming games
|
||||
*/
|
||||
@Query(value = "select ug.* from " + IN_BACKLOG_ORDER + "where ug.user_id = :userId and ug.status = 'BACKLOG' " +
|
||||
"and (:released = false or " + NOT_COMING + ") order by bo.position, ug.appid limit :limit",
|
||||
nativeQuery = true)
|
||||
List<UserGame> findBacklogInOrder(@Param("userId") Long userId,
|
||||
@Param("released") boolean released,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* The first backlog games of the user's own order that pass the picker filters.
|
||||
*
|
||||
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||
* @param mood only games known to have this mood; null for every game
|
||||
* @param excludeAppids never picked; may be empty
|
||||
*/
|
||||
default List<UserGame> findNextUpGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
|
||||
return queryNextUpGames(userId, maxMinutes, storedMood(mood), mood == Mood.SPEEDRUN, excludedOrNone(excludeAppids), count);
|
||||
}
|
||||
|
||||
@Query(value = "select ug.* from " + IN_BACKLOG_ORDER + "where " + PICKER_FILTERS +
|
||||
"order by bo.position, ug.appid limit :count", nativeQuery = true)
|
||||
List<UserGame> queryNextUpGames(@Param("userId") Long userId,
|
||||
@Param("maxMinutes") Integer maxMinutes,
|
||||
@Param("mood") String mood,
|
||||
@Param("speedrun") boolean speedrun,
|
||||
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||
@Param("count") int count);
|
||||
|
||||
/**
|
||||
* Backlog games that share a mood with the anchor game, the most shared moods first, then HIGH priority first.
|
||||
* Empty when the anchor has no known moods.
|
||||
|
||||
@@ -2,10 +2,12 @@ package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||
import core_service.modules.library.api.dto.BacklogPatch;
|
||||
import core_service.modules.library.domain.BacklogOrder;
|
||||
import core_service.modules.library.domain.FinishedHintType;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.repo.BacklogOrderRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.shared.security.RequiresRole;
|
||||
import core_service.shared.security.Role;
|
||||
@@ -14,6 +16,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@@ -22,6 +25,7 @@ import java.util.Optional;
|
||||
public class BacklogService {
|
||||
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final BacklogOrderRepository backlogOrderRepository;
|
||||
|
||||
/**
|
||||
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
|
||||
@@ -61,6 +65,16 @@ public class BacklogService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's backlog order. Appids not in the backlog are ignored; backlog games not sent go after the sent
|
||||
* ones in their old order.
|
||||
*/
|
||||
@Transactional
|
||||
public void reorder(Long userId, List<Long> appids) {
|
||||
List<Long> current = backlogOrderRepository.lockOrder(userId);
|
||||
backlogOrderRepository.writeOrder(userId, BacklogOrder.reorder(current, appids));
|
||||
}
|
||||
|
||||
public enum HideResult { DONE, NOT_FOUND }
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||
import core_service.modules.library.api.dto.ActivityDto;
|
||||
import core_service.modules.library.api.dto.BacklogOrderDto;
|
||||
import core_service.modules.library.api.dto.GameDto;
|
||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||
import core_service.modules.library.api.dto.PickReasonDto;
|
||||
@@ -53,6 +54,7 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -74,6 +76,7 @@ public class LibraryQueryService {
|
||||
private static final int CLOSEST_GAMES = 5;
|
||||
private static final int RECENT_GAMES = 5;
|
||||
private static final int ANCHOR_CANDIDATES = 5;
|
||||
private static final int NEXT_UP_CANDIDATES = 5;
|
||||
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
@@ -108,8 +111,9 @@ public class LibraryQueryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixes three sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting
|
||||
* longest, and a random one (HIGH priority first). A source with nothing left gives its slot to a random game.
|
||||
* Mixes four sources, one per slot in turn: a backlog game like a game the user played a lot, the game waiting
|
||||
* longest, a random one (HIGH priority first), and a random one of the first five of the user's own backlog
|
||||
* order. A source with nothing left gives its slot to a random game.
|
||||
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
|
||||
*/
|
||||
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
|
||||
@@ -125,7 +129,21 @@ public class LibraryQueryService {
|
||||
List<UserGame> similar = anchor
|
||||
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count))
|
||||
.orElse(List.of());
|
||||
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, count), anchor));
|
||||
List<UserGame> nextUp = new ArrayList<>(userGameRepository.findNextUpGames(userId, maxMinutes, mood,
|
||||
excludeAppids, NEXT_UP_CANDIDATES));
|
||||
Collections.shuffle(nextUp);
|
||||
return new PickerDto(withReasons(PickMix.mix(similar, longest, random, nextUp, count), anchor));
|
||||
}
|
||||
|
||||
/**
|
||||
* The listed backlog in the user's own order.
|
||||
*
|
||||
* @param released true leaves out the coming games
|
||||
* @param limit the most games; null for the whole backlog
|
||||
*/
|
||||
public BacklogOrderDto listBacklog(Long userId, boolean released, Integer limit) {
|
||||
return new BacklogOrderDto(toDtos(userGameRepository.findBacklogInOrder(userId, released,
|
||||
limit == null ? Integer.MAX_VALUE : limit)));
|
||||
}
|
||||
|
||||
private List<PickedGameDto> withReasons(List<PickMix.Pick> picks, Optional<UserGame> anchor) {
|
||||
@@ -145,6 +163,7 @@ public class LibraryQueryService {
|
||||
case SIMILAR -> PickReasonDto.similar(anchor.orElseThrow(), anchorName);
|
||||
case LONGEST -> PickReasonDto.longest(pick.game().getFirstSeenAt());
|
||||
case RANDOM -> PickReasonDto.random();
|
||||
case NEXT_UP -> PickReasonDto.nextUp();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- The user's own backlog order (Deployment #3). A row exists exactly while the game is listed BACKLOG
|
||||
-- (status BACKLOG, not removed, not hidden); the trigger keeps it so, for every code path like V14.
|
||||
-- Lower position = earlier. Ties (rare, two appends at once) sort by appid.
|
||||
CREATE TABLE backlog_order (
|
||||
user_id BIGINT NOT NULL,
|
||||
appid BIGINT NOT NULL,
|
||||
position BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, appid),
|
||||
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX backlog_order_user_position_idx ON backlog_order (user_id, position);
|
||||
-- First order: priority HIGH, MEDIUM, then the rest, then the date added.
|
||||
INSERT INTO backlog_order (user_id, appid, position)
|
||||
SELECT user_id, appid, row_number() OVER (PARTITION BY user_id ORDER BY
|
||||
CASE priority WHEN 'HIGH' THEN 0 WHEN 'MEDIUM' THEN 1 ELSE 2 END, first_seen_at, appid)
|
||||
FROM listed_user_game WHERE status = 'BACKLOG';
|
||||
|
||||
CREATE FUNCTION sync_backlog_order() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'BACKLOG' AND NEW.removed_at IS NULL AND NEW.hidden_at IS NULL THEN
|
||||
IF TG_OP = 'INSERT' OR NOT (OLD.status = 'BACKLOG' AND OLD.removed_at IS NULL AND OLD.hidden_at IS NULL) THEN
|
||||
INSERT INTO backlog_order (user_id, appid, position)
|
||||
SELECT NEW.user_id, NEW.appid, coalesce(max(b.position), 0) + 1
|
||||
FROM backlog_order b WHERE b.user_id = NEW.user_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
DELETE FROM backlog_order WHERE user_id = NEW.user_id AND appid = NEW.appid;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER backlog_order_insert AFTER INSERT ON user_game
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_backlog_order();
|
||||
CREATE TRIGGER backlog_order_update AFTER UPDATE OF status, removed_at, hidden_at ON user_game
|
||||
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.removed_at IS DISTINCT FROM NEW.removed_at
|
||||
OR OLD.hidden_at IS DISTINCT FROM NEW.hidden_at)
|
||||
EXECUTE FUNCTION sync_backlog_order();
|
||||
@@ -232,6 +232,18 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
||||
"STARTED " + MANUAL, "STARTED " + STEAM);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_BothInBacklog_KeptGameTakesTheBetterPosition() throws Exception {
|
||||
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
|
||||
userGameRepository.save(new UserGame(AUDITOR_ID, STEAM_2, 0, Instant.now(), Instant.now()));
|
||||
userGameRepository.save(new UserGame(AUDITOR_ID, STEAM, 0, Instant.now(), Instant.now()));
|
||||
|
||||
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
|
||||
|
||||
assertThat(jdbc.queryForList("select appid from backlog_order where user_id = ? order by position, appid",
|
||||
Long.class, AUDITOR_ID)).containsExactly(STEAM, STEAM_2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
|
||||
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.repo.GameReleaseRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.shared.test.AbstractMockedLibraryServicesTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The dev profile authenticates every request as user 1.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class LibraryControllerBacklogOrderTest extends AbstractMockedLibraryServicesTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameReleaseRepository gameReleaseRepository;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
gameReleaseRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
}
|
||||
|
||||
private void game(long userId, long appid, GameStatus status) {
|
||||
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||
UserGame game = new UserGame(userId, appid, 0, Instant.now(), Instant.now());
|
||||
game.setStatus(status);
|
||||
userGameRepository.save(game);
|
||||
}
|
||||
|
||||
private ResultActions putOrder(String appidsJson) throws Exception {
|
||||
return mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"appids\":" + appidsJson + "}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBacklog_ReturnsTheGamesInOrder_AndLimitReturnsTheFirst() throws Exception {
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(USER_ID, 20, GameStatus.BACKLOG);
|
||||
game(USER_ID, 30, GameStatus.BACKLOG);
|
||||
game(USER_ID, 40, GameStatus.PLAYING);
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(10, 20, 30)));
|
||||
mockMvc.perform(get("/api/library/backlog").param("limit", "2"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBacklog_ComingGame_IsShownAndCanBeReordered_ReleasedLeavesItOut() throws Exception {
|
||||
LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(USER_ID, 20, GameStatus.BACKLOG);
|
||||
gameReleaseRepository.upsert(20L, berlinToday.plusDays(1), false, Instant.now());
|
||||
|
||||
putOrder("[20,10]").andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(20, 10)))
|
||||
.andExpect(jsonPath("$.games[0].coming").value(true));
|
||||
mockMvc.perform(get("/api/library/backlog").param("released", "true"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(10)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putBacklog_ThenGet_ReturnsTheNewOrder() throws Exception {
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(USER_ID, 20, GameStatus.BACKLOG);
|
||||
game(USER_ID, 30, GameStatus.BACKLOG);
|
||||
|
||||
putOrder("[30,10,20]").andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(30, 10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putBacklog_AnotherUsersAppid_DoesNotChangeThatUsersOrder() throws Exception {
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(2L, 20, GameStatus.BACKLOG);
|
||||
game(2L, 30, GameStatus.BACKLOG);
|
||||
|
||||
putOrder("[30,10,20]").andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(10)));
|
||||
assertThat(userGameRepository.findBacklogInOrder(2L, false, 10))
|
||||
.extracting(UserGame::getAppid).containsExactly(20L, 30L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void putBacklog_PartialList_LeavesTheMissingGamesAfterTheSentOnes() throws Exception {
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(USER_ID, 20, GameStatus.BACKLOG);
|
||||
game(USER_ID, 30, GameStatus.BACKLOG);
|
||||
|
||||
putOrder("[30]").andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(30, 10, 20)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBacklog_Empty_ReturnsNoGames() throws Exception {
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putBacklog_NullElement_Is400() throws Exception {
|
||||
putOrder("[10,null]").andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putBacklog_MoreThan20000Appids_Is400() throws Exception {
|
||||
String tooMany = LongStream.rangeClosed(1, 20001).mapToObj(Long::toString)
|
||||
.collect(Collectors.joining(",", "[", "]"));
|
||||
|
||||
putOrder(tooMany).andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBacklog_GameSetToFinished_IsGone() throws Exception {
|
||||
game(USER_ID, 10, GameStatus.BACKLOG);
|
||||
game(USER_ID, 20, GameStatus.BACKLOG);
|
||||
|
||||
mockMvc.perform(put("/api/library/games/10/status").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"FINISHED\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/api/library/backlog"))
|
||||
.andExpect(jsonPath("$.games[*].appid", contains(20)));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.configuration.security.ClientHeaderFilter;
|
||||
import core_service.modules.library.domain.GamePriority;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
@@ -11,24 +12,30 @@ import core_service.modules.library.repo.GameReleaseRepository;
|
||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.shared.test.AbstractMockedLibraryServicesTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -63,9 +70,14 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
@Autowired
|
||||
private GameReleaseRepository gameReleaseRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository achievementMetaRepository;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
gameReleaseRepository.deleteAll();
|
||||
// A game with unlocked achievements is never coming; other test classes leave such rows.
|
||||
achievementMetaRepository.deleteAll();
|
||||
userGameRepository.deleteAll();
|
||||
gameTimeToBeatRepository.deleteAll();
|
||||
gameMoodsRepository.deleteAll();
|
||||
@@ -306,4 +318,47 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
||||
mockMvc.perform(get("/api/library/picker"))
|
||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
||||
}
|
||||
|
||||
private void putOrder(Long... appids) throws Exception {
|
||||
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"appids\":" + Arrays.toString(appids) + "}"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_FourSlots_NextUpComesFromTopFiveWithReason() throws Exception {
|
||||
for (long appid = 1; appid <= 7; appid++) {
|
||||
game(appid, GameStatus.BACKLOG, null, null);
|
||||
}
|
||||
putOrder(7L, 6L, 5L, 4L, 3L, 2L, 1L);
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "4"))
|
||||
.andExpect(jsonPath("$.games", hasSize(4)))
|
||||
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
|
||||
.andExpect(jsonPath("$.games[3].appid", anyOf(is(7), is(6), is(5), is(4), is(3))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_NextUp_SkipsExcludedAndFilteredGames() throws Exception {
|
||||
for (long appid = 1; appid <= 12; appid++) {
|
||||
game(appid, GameStatus.BACKLOG, null, 60);
|
||||
}
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "4").param("exclude", "1", "2", "3", "4", "5"))
|
||||
.andExpect(jsonPath("$.games[3].reason.source").value("NEXT_UP"))
|
||||
.andExpect(jsonPath("$.games[3].appid", anyOf(is(6), is(7), is(8), is(9), is(10))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picker_NextUp_NeverPicksAComingGame() throws Exception {
|
||||
LocalDate berlinToday = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||
game(10L, GameStatus.BACKLOG, null, null);
|
||||
game(20L, GameStatus.BACKLOG, null, null);
|
||||
gameReleaseRepository.upsert(10L, berlinToday.plusDays(1), false, Instant.now());
|
||||
|
||||
mockMvc.perform(get("/api/library/picker").param("count", "4"))
|
||||
.andExpect(jsonPath("$.games", hasSize(1)))
|
||||
.andExpect(jsonPath("$.games[0].appid").value(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class BacklogOrderTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void reorder_KeepsTheSentOrder() {
|
||||
assertEquals(List.of(3L, 1L, 2L), BacklogOrder.reorder(List.of(1L, 2L, 3L), List.of(3L, 1L, 2L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_IgnoresUnknownAppids() {
|
||||
assertEquals(List.of(2L, 1L), BacklogOrder.reorder(List.of(1L, 2L), List.of(99L, 2L, 1L, 98L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_KeepsADuplicateOnce() {
|
||||
assertEquals(List.of(2L, 1L), BacklogOrder.reorder(List.of(1L, 2L), List.of(2L, 2L, 1L, 2L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_AppendsTheMissingInTheirOldOrder() {
|
||||
assertEquals(List.of(4L, 1L, 2L, 3L, 5L), BacklogOrder.reorder(List.of(1L, 2L, 3L, 4L, 5L), List.of(4L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_EmptyWanted_KeepsCurrent() {
|
||||
assertEquals(List.of(1L, 2L, 3L), BacklogOrder.reorder(List.of(1L, 2L, 3L), List.of()));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static core_service.modules.library.domain.PickSource.LONGEST;
|
||||
import static core_service.modules.library.domain.PickSource.NEXT_UP;
|
||||
import static core_service.modules.library.domain.PickSource.RANDOM;
|
||||
import static core_service.modules.library.domain.PickSource.SIMILAR;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -34,7 +35,7 @@ class PickMixTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void mix_ThreeSources_OneGameEachInSlotOrder() {
|
||||
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), 3);
|
||||
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), List.of(), 3);
|
||||
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
|
||||
assertEquals(List.of(1L, 2L, 3L), appids(picks));
|
||||
@@ -42,15 +43,16 @@ class PickMixTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void mix_MoreSlotsThanSources_RotationContinues() {
|
||||
List<Pick> picks = PickMix.mix(games(1, 4, 7), games(2, 5), games(3, 6), 7);
|
||||
List<Pick> picks = PickMix.mix(games(1, 5, 9), games(2, 6), games(3, 7), games(4, 8), 9);
|
||||
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, SIMILAR, LONGEST, RANDOM, SIMILAR), sources(picks));
|
||||
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), appids(picks));
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR),
|
||||
sources(picks));
|
||||
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L), appids(picks));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mix_SameGameFirstInAllLists_AppearsOnce() {
|
||||
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), 3);
|
||||
List<Pick> picks = PickMix.mix(games(1, 4), games(1, 5), games(1, 6), List.of(), 3);
|
||||
|
||||
assertEquals(List.of(1L, 5L, 6L), appids(picks));
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
|
||||
@@ -58,7 +60,7 @@ class PickMixTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void mix_EmptySimilar_SlotComesFromRandom() {
|
||||
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), 3);
|
||||
List<Pick> picks = PickMix.mix(List.of(), games(2), games(3, 4), List.of(), 3);
|
||||
|
||||
assertEquals(List.of(RANDOM, LONGEST, RANDOM), sources(picks));
|
||||
assertEquals(List.of(3L, 2L, 4L), appids(picks));
|
||||
@@ -66,7 +68,7 @@ class PickMixTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void mix_ListsShorterThanCount_ReturnsAllDistinctGames() {
|
||||
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), 10);
|
||||
List<Pick> picks = PickMix.mix(games(1), games(1, 2), games(2, 3), List.of(), 10);
|
||||
|
||||
assertEquals(List.of(1L, 2L, 3L), appids(picks).stream().sorted().toList());
|
||||
assertEquals(3, picks.size());
|
||||
@@ -74,6 +76,22 @@ class PickMixTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void mix_AllEmpty_ReturnsNothing() {
|
||||
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), 3).isEmpty());
|
||||
assertTrue(PickMix.mix(List.of(), List.of(), List.of(), List.of(), 3).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mix_FourSources_NextUpIsTheFourthSlot() {
|
||||
List<Pick> picks = PickMix.mix(games(1), games(2), games(3), games(4), 4);
|
||||
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, NEXT_UP), sources(picks));
|
||||
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mix_EmptyNextUp_SlotComesFromRandom() {
|
||||
List<Pick> picks = PickMix.mix(games(1), games(2), games(3, 4), List.of(), 4);
|
||||
|
||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM, RANDOM), sources(picks));
|
||||
assertEquals(List.of(1L, 2L, 3L, 4L), appids(picks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(BacklogOrderRepository.class)
|
||||
class BacklogOrderRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final long USER = 1L;
|
||||
|
||||
@Autowired
|
||||
private BacklogOrderRepository backlogOrderRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from game_link");
|
||||
jdbc.update("delete from game_release");
|
||||
jdbc.update("delete from user_game");
|
||||
for (long appid : List.of(10L, 20L, 30L, 40L)) {
|
||||
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
|
||||
}
|
||||
for (long appid : List.of(10L, 20L, 30L)) {
|
||||
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, 'BACKLOG')", USER, appid);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> backlog(boolean released) {
|
||||
return userGameRepository.findBacklogInOrder(USER, released, Integer.MAX_VALUE).stream()
|
||||
.map(UserGame::getAppid).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeOrder_AfterLockOrder_GivesPositionsOneToN() {
|
||||
assertThat(backlogOrderRepository.lockOrder(USER)).containsExactly(10L, 20L, 30L);
|
||||
|
||||
backlogOrderRepository.writeOrder(USER, List.of(30L, 10L, 20L));
|
||||
|
||||
assertThat(jdbc.queryForList("select position from backlog_order where user_id = ? order by position",
|
||||
Long.class, USER)).containsExactly(1L, 2L, 3L);
|
||||
assertThat(backlogOrderRepository.lockOrder(USER)).containsExactly(30L, 10L, 20L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBacklogInOrder_FollowsThePosition_AndLimit() {
|
||||
backlogOrderRepository.writeOrder(USER, List.of(30L, 10L, 20L));
|
||||
|
||||
assertThat(backlog(false)).containsExactly(30L, 10L, 20L);
|
||||
assertThat(userGameRepository.findBacklogInOrder(USER, false, 2)).extracting(UserGame::getAppid)
|
||||
.containsExactly(30L, 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBacklogInOrder_LeavesOutALesserEditionOfALinkedSet() {
|
||||
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, 40, 'BACKLOG', 100)", USER);
|
||||
jdbc.update("insert into game_link (appid, set_id) values (10, 1), (40, 1)");
|
||||
|
||||
assertThat(backlog(false)).containsExactly(20L, 30L, 40L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBacklogInOrder_ReleasedLeavesOutAComingGame() {
|
||||
jdbc.update("insert into game_release (appid, release_date, fetched_at) values (20, current_date + 30, now())");
|
||||
|
||||
assertThat(backlog(true)).containsExactly(10L, 30L);
|
||||
assertThat(backlog(false)).containsExactly(10L, 20L, 30L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The trigger of V34 that keeps backlog_order.
|
||||
*/
|
||||
class BacklogOrderTriggerTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final long USER = 1L;
|
||||
private static final long OTHER_USER = 2L;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from user_game");
|
||||
for (long appid = 1; appid <= 300; appid++) {
|
||||
jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid);
|
||||
}
|
||||
}
|
||||
|
||||
private void own(long userId, long appid, String status) {
|
||||
jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, ?)", userId, appid, status);
|
||||
}
|
||||
|
||||
private void setStatus(long appid, String status) {
|
||||
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER, appid);
|
||||
}
|
||||
|
||||
private List<Long> order(long userId) {
|
||||
return jdbc.queryForList("select appid from backlog_order where user_id = ? order by position, appid",
|
||||
Long.class, userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertedBacklogGame_GoesToTheBottom() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
own(USER, 2, "BACKLOG");
|
||||
own(OTHER_USER, 3, "BACKLOG");
|
||||
|
||||
assertThat(order(USER)).containsExactly(1L, 2L);
|
||||
assertThat(order(OTHER_USER)).containsExactly(3L);
|
||||
assertThat(jdbc.queryForObject("select position from backlog_order where user_id = ? and appid = 3",
|
||||
Long.class, OTHER_USER)).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertedPlayingGame_HasNoRow() {
|
||||
own(USER, 1, "PLAYING");
|
||||
|
||||
assertThat(order(USER)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavingBacklog_DeletesTheRow_AndComingBackPutsItAtTheBottom() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
own(USER, 2, "BACKLOG");
|
||||
|
||||
setStatus(1, "FINISHED");
|
||||
assertThat(order(USER)).containsExactly(2L);
|
||||
|
||||
setStatus(1, "BACKLOG");
|
||||
assertThat(order(USER)).containsExactly(2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hideAndRemove_DeleteTheRow_AndUnhidePutsItAtTheBottom() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
own(USER, 2, "BACKLOG");
|
||||
own(USER, 3, "BACKLOG");
|
||||
|
||||
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = 1", USER);
|
||||
jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = 2", USER);
|
||||
assertThat(order(USER)).containsExactly(3L);
|
||||
|
||||
jdbc.update("update user_game set hidden_at = null where user_id = ? and appid = 1", USER);
|
||||
assertThat(order(USER)).containsExactly(3L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateWithoutStatusHideOrRemoveChange_KeepsThePosition() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
own(USER, 2, "BACKLOG");
|
||||
jdbc.update("update backlog_order set position = 5 where user_id = ? and appid = 1", USER);
|
||||
|
||||
jdbc.update("update user_game set status = 'BACKLOG', notes = 'x', hidden_at = null where user_id = ? and appid = 1",
|
||||
USER);
|
||||
|
||||
assertThat(order(USER)).containsExactly(2L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void manyNewGamesAfterAReorder_KeepTheFirstPositions() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
own(USER, 2, "BACKLOG");
|
||||
own(USER, 3, "BACKLOG");
|
||||
jdbc.update("update backlog_order set position = 3 where user_id = ? and appid = 1", USER);
|
||||
jdbc.update("update backlog_order set position = 2 where user_id = ? and appid = 2", USER);
|
||||
jdbc.update("update backlog_order set position = 1 where user_id = ? and appid = 3", USER);
|
||||
|
||||
for (long appid = 100; appid < 300; appid++) {
|
||||
own(USER, appid, "BACKLOG");
|
||||
}
|
||||
|
||||
List<Long> expected = new ArrayList<>(List.of(3L, 2L, 1L));
|
||||
for (long appid = 100; appid < 300; appid++) {
|
||||
expected.add(appid);
|
||||
}
|
||||
assertThat(order(USER)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingTheGame_DeletesTheOrderRow() {
|
||||
own(USER, 1, "BACKLOG");
|
||||
|
||||
jdbc.update("delete from user_game where user_id = ? and appid = 1", USER);
|
||||
|
||||
assertThat(order(USER)).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user