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.
|
can be taken over; its own finish then changes nothing.
|
||||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
- **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.
|
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
|
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,
|
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`).
|
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`,
|
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.
|
`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
|
- **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
|
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
|
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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A["anchor: random of top 5 played"] --> S[similar]
|
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
|
L[longest waiting] --> M
|
||||||
R[random] --> M
|
R[random] --> M
|
||||||
|
N["next up: random of top 5 in order"] --> M
|
||||||
M --> O["games + reason"]
|
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
|
- **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 |
|
| `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/sync` | JWT |
|
||||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | 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/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?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 |
|
| `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.
|
// 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",
|
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",
|
"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");
|
"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.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
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.BacklogPatch;
|
||||||
import core_service.modules.library.api.dto.AuditEntryDto;
|
import core_service.modules.library.api.dto.AuditEntryDto;
|
||||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
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_ACTIVITY_DAYS = 90;
|
||||||
private static final int MAX_PICKER_GAMES = 10;
|
private static final int MAX_PICKER_GAMES = 10;
|
||||||
private static final int MAX_PICKER_EXCLUDES = 100;
|
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 static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
@@ -465,6 +468,21 @@ public class LibraryController {
|
|||||||
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
|
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")
|
@GetMapping("/picker")
|
||||||
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestParam(required = false) Integer maxMinutes,
|
@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() {
|
public static PickReasonDto random() {
|
||||||
return new PickReasonDto(PickSource.RANDOM, null, null, null, null);
|
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;
|
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 {
|
public final class PickMix {
|
||||||
|
|
||||||
@@ -15,13 +15,15 @@ public final class PickMix {
|
|||||||
private 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.
|
* Stops early when no game is left.
|
||||||
*
|
*
|
||||||
* @param count the most picks
|
* @param count the most picks
|
||||||
*/
|
*/
|
||||||
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random, int count) {
|
public static List<Pick> mix(List<UserGame> similar, List<UserGame> longest, List<UserGame> random,
|
||||||
List<List<UserGame>> bySource = List.of(similar, longest, random);
|
List<UserGame> nextUp, int count) {
|
||||||
|
List<List<UserGame>> bySource = List.of(similar, longest, random, nextUp);
|
||||||
Set<Long> used = new HashSet<>();
|
Set<Long> used = new HashSet<>();
|
||||||
List<Pick> picks = new ArrayList<>();
|
List<Pick> picks = new ArrayList<>();
|
||||||
PickSource[] sources = PickSource.values();
|
PickSource[] sources = PickSource.values();
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ package core_service.modules.library.domain;
|
|||||||
public enum PickSource {
|
public enum PickSource {
|
||||||
SIMILAR,
|
SIMILAR,
|
||||||
LONGEST,
|
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("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 "
|
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);
|
+ "(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 "
|
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);
|
+ "(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);
|
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)) " +
|
" where s.appid = ug.appid and s.weblink is not null)) " +
|
||||||
"and " + NOT_COMING;
|
"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";
|
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("excludeAppids") Collection<Long> excludeAppids,
|
||||||
@Param("count") int count);
|
@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.
|
* 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.
|
* 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.BacklogEntryDto;
|
||||||
import core_service.modules.library.api.dto.BacklogPatch;
|
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.FinishedHintType;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import core_service.modules.library.repo.BacklogOrderRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
@@ -14,6 +16,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -22,6 +25,7 @@ import java.util.Optional;
|
|||||||
public class BacklogService {
|
public class BacklogService {
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
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
|
* 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 }
|
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.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
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.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
import core_service.modules.library.api.dto.PickReasonDto;
|
import core_service.modules.library.api.dto.PickReasonDto;
|
||||||
@@ -53,6 +54,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -74,6 +76,7 @@ public class LibraryQueryService {
|
|||||||
private static final int CLOSEST_GAMES = 5;
|
private static final int CLOSEST_GAMES = 5;
|
||||||
private static final int RECENT_GAMES = 5;
|
private static final int RECENT_GAMES = 5;
|
||||||
private static final int ANCHOR_CANDIDATES = 5;
|
private static final int ANCHOR_CANDIDATES = 5;
|
||||||
|
private static final int NEXT_UP_CANDIDATES = 5;
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
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
|
* Mixes four 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.
|
* 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.
|
* 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) {
|
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
|
List<UserGame> similar = anchor
|
||||||
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count))
|
.map(a -> userGameRepository.findSimilarGames(userId, a.getAppid(), maxMinutes, mood, excludeAppids, count))
|
||||||
.orElse(List.of());
|
.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) {
|
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 SIMILAR -> PickReasonDto.similar(anchor.orElseThrow(), anchorName);
|
||||||
case LONGEST -> PickReasonDto.longest(pick.game().getFirstSeenAt());
|
case LONGEST -> PickReasonDto.longest(pick.game().getFirstSeenAt());
|
||||||
case RANDOM -> PickReasonDto.random();
|
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);
|
"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
|
@Test
|
||||||
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
|
void userWithBothRows_KeepsTheSteamRowWithTheNewerHandSetFields() throws Exception {
|
||||||
UserGame steam = new UserGame(AUDITOR_ID, STEAM, 300, Instant.now(), Instant.now());
|
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;
|
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.GamePriority;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
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.GameSpeedrunRepository;
|
||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
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.UserGameRepository;
|
||||||
import core_service.shared.test.AbstractMockedLibraryServicesTest;
|
import core_service.shared.test.AbstractMockedLibraryServicesTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
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.MockMvc;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
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.contains;
|
||||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.hamcrest.Matchers.is;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
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.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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -63,9 +70,14 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private GameReleaseRepository gameReleaseRepository;
|
private GameReleaseRepository gameReleaseRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameAchievementMetaRepository achievementMetaRepository;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void clean() {
|
void clean() {
|
||||||
gameReleaseRepository.deleteAll();
|
gameReleaseRepository.deleteAll();
|
||||||
|
// A game with unlocked achievements is never coming; other test classes leave such rows.
|
||||||
|
achievementMetaRepository.deleteAll();
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
gameTimeToBeatRepository.deleteAll();
|
gameTimeToBeatRepository.deleteAll();
|
||||||
gameMoodsRepository.deleteAll();
|
gameMoodsRepository.deleteAll();
|
||||||
@@ -306,4 +318,47 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
|
|||||||
mockMvc.perform(get("/api/library/picker"))
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
.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 java.util.List;
|
||||||
|
|
||||||
import static core_service.modules.library.domain.PickSource.LONGEST;
|
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.RANDOM;
|
||||||
import static core_service.modules.library.domain.PickSource.SIMILAR;
|
import static core_service.modules.library.domain.PickSource.SIMILAR;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -34,7 +35,7 @@ class PickMixTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_ThreeSources_OneGameEachInSlotOrder() {
|
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(SIMILAR, LONGEST, RANDOM), sources(picks));
|
||||||
assertEquals(List.of(1L, 2L, 3L), appids(picks));
|
assertEquals(List.of(1L, 2L, 3L), appids(picks));
|
||||||
@@ -42,15 +43,16 @@ class PickMixTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_MoreSlotsThanSources_RotationContinues() {
|
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(SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR, LONGEST, RANDOM, NEXT_UP, SIMILAR),
|
||||||
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L), appids(picks));
|
sources(picks));
|
||||||
|
assertEquals(List.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L), appids(picks));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_SameGameFirstInAllLists_AppearsOnce() {
|
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(1L, 5L, 6L), appids(picks));
|
||||||
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
|
assertEquals(List.of(SIMILAR, LONGEST, RANDOM), sources(picks));
|
||||||
@@ -58,7 +60,7 @@ class PickMixTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_EmptySimilar_SlotComesFromRandom() {
|
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(RANDOM, LONGEST, RANDOM), sources(picks));
|
||||||
assertEquals(List.of(3L, 2L, 4L), appids(picks));
|
assertEquals(List.of(3L, 2L, 4L), appids(picks));
|
||||||
@@ -66,7 +68,7 @@ class PickMixTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_ListsShorterThanCount_ReturnsAllDistinctGames() {
|
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(List.of(1L, 2L, 3L), appids(picks).stream().sorted().toList());
|
||||||
assertEquals(3, picks.size());
|
assertEquals(3, picks.size());
|
||||||
@@ -74,6 +76,22 @@ class PickMixTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mix_AllEmpty_ReturnsNothing() {
|
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