Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 9efed0ab1d feat(speedrun): match speedrun.com games only when sure
Build, Test & Push Core Service / docker (push) Successful in 1m29s
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Loose name matches gave wrong links. Now a link needs one sure hit:
same name and within 1 year of IGDB, or one hit when no year is known.
V47 marks old matches (not corrected ones) for one re-check. Refs #8
2026-10-11 17:23:32 +02:00
lobo bb195a1f79 test(library): compare trend move times as Instants
Sort the parsed Instant values, not the raw strings. String order
is wrong when fraction lengths differ. Refs #45.
2026-10-11 16:46:03 +02:00
15 changed files with 603 additions and 80 deletions

No files matched your search

+6 -3
View File
@@ -67,10 +67,13 @@ To Be Played is split into three repositories:
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
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days.
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. Migration V47 asks
every stored match again once at boot, behind user requests; links set by an auditor are never touched.
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* asks again.
*/
@Configuration
@@ -43,4 +43,14 @@ public class AsyncConfig {
executor.setThreadNamePrefix("sync-follow-up-");
return executor;
}
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
@Bean(name = BackgroundPools.SPEEDRUN)
public ThreadPoolTaskExecutor speedrunExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setThreadNamePrefix("speedrun-");
return executor;
}
}
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -77,6 +78,9 @@ public class IgdbApiService {
*/
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
/** @param year null when IGDB knows no release date */
public record NameAndYear(String name, Integer year) {}
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
private static final int SEARCH_FETCH_LIMIT = 50;
private static final int SEARCH_LIMIT = 20;
@@ -382,18 +386,31 @@ public class IgdbApiService {
* Like {@link #getReleaseDates}, but for IGDB game ids.
*/
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
return inBatches(igdbIds, "release dates", this::datesOf);
}
/**
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
* when the adapter is off, nothing was asked or a request failed.
*/
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
}
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
Function<List<Long>, Map<Long, T>> batchQuery) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, LocalDate> result = new HashMap<>();
Map<Long, T> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for release dates", e);
log.error("Error communicating with IGDB for {}", what, e);
return Optional.empty();
}
}
@@ -601,10 +618,7 @@ public class IgdbApiService {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.Game[] games = query("/games",
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
Map<Long, LocalDate> result = new HashMap<>();
for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
@@ -614,6 +628,23 @@ public class IgdbApiService {
return result;
}
private Map<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
Map<Long, NameAndYear> result = new HashMap<>();
for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) {
String name = trimmedName(game);
if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) {
result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate())));
}
}
return result;
}
private IgdbResponses.Game[] gamesById(Collection<Long> igdbIds, String fields) {
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
}
private static int year(long unixSeconds) {
return date(unixSeconds).getYear();
}
@@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Set;
public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long> {
@@ -48,4 +49,12 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
@Modifying
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
/**
* Rows marked for a new check (checked_at <= before) of games some user lists, without an auditor's link.
*/
@Query(value = "select appid from game_speedrun where checked_at <= :before and not " + CORRECTED
+ " and exists (select 1 from listed_user_game ug where ug.appid = game_speedrun.appid) order by appid",
nativeQuery = true)
List<Long> findAppidsToRecheck(@Param("before") Instant before);
}
@@ -6,21 +6,33 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Finds a library game among speedrun.com's search hits. speedrun.com has no Steam or IGDB link, so the name must be
* the same and, when both years are known, the release year may differ by at most one.
* Finds a library game among speedrun.com's search hits, or none: a wrong link is worse than no link. speedrun.com has
* no Steam or IGDB link, so the name must be the same (case, punctuation, trademark signs and roman numerals do not
* count). With known years the year of the hit must be within one of ours. Without a year to decide, only a single
* same-name hit counts.
*/
final class SpeedrunMatch {
static final int MAX_CANDIDATES = 2;
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
// Other pages on speedrun.com ("Remastered", "Remake") are left alone on purpose.
private static final Pattern EDITION_SUFFIX = Pattern.compile(
"\\s*[-:\u2013]?\\s*(?:the\\s+)?(?:(?:game of the year|goty|special|definitive|complete|deluxe|digital deluxe|enhanced|gold|"
+ "ultimate|anniversary|legendary|premium) edition|goty|director'?s cut)\\s*$",
Pattern.CASE_INSENSITIVE);
// Index + 2 is the number.
private static final List<String> ROMAN_NUMERALS = List.of("ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x",
"xi", "xii", "xiii", "xiv", "xv", "xvi", "xvii", "xviii", "xix", "xx");
// "i" and "x" stay: "Mega Man X" is not "Mega Man 10".
private static final Pattern ROMAN_NUMERAL = Pattern.compile("\\b(?!x\\b)(" + String.join("|", ROMAN_NUMERALS) + ")\\b");
// The column sizes of game_speedrun.
private static final int MAX_GAME_ID_LENGTH = 16;
private static final int MAX_WEBLINK_LENGTH = 512;
@@ -39,23 +51,40 @@ final class SpeedrunMatch {
/**
* @param years our release years of the game; empty when unknown
* @param hits in speedrun.com's order
* @return the hits worth asking for runs, best first, at most {@link #MAX_CANDIDATES}
* @return the one hit that is this game; empty when no hit is, or when several could be
*/
static List<SpeedrunApiService.Game> candidates(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
static Optional<SpeedrunApiService.Game> sure(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
NameAndYear ours = split(name);
Set<Integer> ourYears = new HashSet<>(years);
if (ours.year() != null) ourYears.add(ours.year());
return hits.stream()
List<SpeedrunApiService.Game> same = hits.stream()
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
.filter(hit -> isWeblink(hit.weblink()))
.filter(hit -> {
NameAndYear theirs = split(hit.name());
Integer year = theirs.year() != null ? theirs.year() : hit.year();
return theirs.name().equals(ours.name()) && (year == null || ourYears.isEmpty()
|| ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1));
})
.limit(MAX_CANDIDATES)
.filter(hit -> split(hit.name()).name().equals(ours.name()))
.toList();
if (ourYears.isEmpty()) {
return only(same);
}
List<SpeedrunApiService.Game> dated = same.stream()
.filter(hit -> {
Integer year = yearOf(hit);
return year != null && ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1);
})
.toList();
if (!dated.isEmpty()) {
return only(dated);
}
return only(same).filter(hit -> yearOf(hit) == null);
}
/**
* @return the name without "Special Edition", "Director's Cut" and the like; empty when it has no such suffix or
* nothing is left
*/
static Optional<String> withoutEditionSuffix(String name) {
String trimmed = name.trim();
String stripped = EDITION_SUFFIX.matcher(trimmed).replaceFirst("").trim();
return stripped.isEmpty() || stripped.equals(trimmed) ? Optional.empty() : Optional.of(stripped);
}
/**
@@ -65,11 +94,25 @@ final class SpeedrunMatch {
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
}
private static Optional<SpeedrunApiService.Game> only(List<SpeedrunApiService.Game> games) {
return games.size() == 1 ? Optional.of(games.get(0)) : Optional.empty();
}
private static Integer yearOf(SpeedrunApiService.Game hit) {
Integer fromName = split(hit.name()).year();
return fromName != null ? fromName : hit.year();
}
private static String key(String name) {
return ROMAN_NUMERAL.matcher(GameNames.normalize(name)).replaceAll(
match -> String.valueOf(ROMAN_NUMERALS.indexOf(match.group()) + 2));
}
private static NameAndYear split(String name) {
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
return matcher.matches()
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(GameNames.normalize(name), null);
? new NameAndYear(key(matcher.group(1)), Integer.parseInt(matcher.group(2)))
: new NameAndYear(key(name), null);
}
private record NameAndYear(String name, Integer year) {}
@@ -1,9 +1,11 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameEdition;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -12,6 +14,8 @@ import core_service.shared.background.BackgroundPools;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -32,7 +36,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
/**
* Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the
* detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread
* works it off: a game in several backlogs is asked once, and the rate limit has one waiter.
* (the speedrun executor) works it off: a game in several backlogs is asked once, and the rate limit has one waiter.
* <p>
* A game is looked up by its own name and years; when that gives no sure hit, an edition is looked up by the name and
* year of its IGDB parent game, a game without a parent by its name without an edition suffix. A sure hit without
* verified runs ends the lookup. See {@link SpeedrunMatch} for "sure".
* <p>
* {@link #recheckMarked} asks the rows a migration marked (checked_at = epoch) again. Those games wait behind every
* game a user asked for.
*/
@Service
@RequiredArgsConstructor
@@ -42,27 +53,53 @@ public class SpeedrunService {
static final int BATCH = 50;
// speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger.
static final int MAX_ERRORS_IN_A_ROW = 3;
// A small batch, so a user request waits for a few games and not for 50.
static final int RECHECK_BATCH = 10;
private final SpeedrunApiService speedrunApiService;
private final IgdbApiService igdbApiService;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final GameEditionRepository gameEditionRepository;
private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository;
private final TaskExecutor syncFollowUpExecutor;
// Works both queues off on its own thread, so the follow-up pool never waits for speedrun.com.
private final TaskExecutor speedrunExecutor;
private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>();
private final ConcurrentSkipListSet<Long> recheck = new ConcurrentSkipListSet<>();
private final AtomicBoolean running = new AtomicBoolean();
@Value("${app.library.speedrun-ttl-days:30}")
private long ttlDays;
/**
* Queues the games without a fresh row and, unless another thread already does it, works the queue off on this
* thread. A game takes up to 3 requests, and there is 1 request per second.
* Queues the games without a fresh row and returns; the speedrun thread works the queue off. A game takes up to 3
* requests, and there is 1 request per second.
*/
public void refresh(Collection<Long> appids) {
if (!speedrunApiService.isEnabled() || appids.isEmpty()) return;
pending.addAll(appids);
speedrunExecutor.execute(this::work);
}
/**
* Queues the rows the V47 migration marked, behind the games users asked for.
*/
@EventListener(ApplicationReadyEvent.class)
public void recheckMarked() {
if (!speedrunApiService.isEnabled()) return;
syncFollowUpExecutor.execute(() -> Refreshes.quietly("Speedrun re-check", () -> {
List<Long> marked = gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH);
if (marked.isEmpty()) return;
log.info("Speedrun re-check of {} games", marked.size());
recheck.addAll(marked);
speedrunExecutor.execute(this::work);
}));
}
private void work() {
// Another work() is draining: it also takes what was queued meanwhile.
while (running.compareAndSet(false, true)) {
try {
drain();
@@ -70,7 +107,7 @@ public class SpeedrunService {
running.set(false);
}
// Games can be queued after the last poll and before the flag was cleared.
if (pending.isEmpty()) return;
if (pending.isEmpty() && recheck.isEmpty()) return;
}
}
@@ -99,12 +136,9 @@ public class SpeedrunService {
private void drain() {
int errorsInARow = 0;
while (!pending.isEmpty()) {
List<Long> batch = new ArrayList<>();
Long next;
while (batch.size() < BATCH && (next = pending.pollFirst()) != null) {
batch.add(next);
}
while (!pending.isEmpty() || !recheck.isEmpty()) {
boolean forUsers = !pending.isEmpty();
List<Long> batch = forUsers ? poll(pending, BATCH) : poll(recheck, RECHECK_BATCH);
batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays))));
if (batch.isEmpty()) continue;
@@ -113,17 +147,24 @@ public class SpeedrunService {
names.put(game.getAppid(), game.getName());
}
Optional<Map<Long, Set<Integer>>> years = releaseYears(batch);
if (years.isEmpty()) {
Optional<Map<Long, IgdbApiService.NameAndYear>> parents = years.isEmpty() ? Optional.empty() : parentNames(batch);
if (parents.isEmpty()) {
// Matching by name only would store a wrong remake for 30 days.
log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size());
continue;
}
for (Long appid : batch) {
for (int i = 0; i < batch.size(); i++) {
if (!forUsers && !pending.isEmpty()) {
// A user asked in the meantime: the rest of the re-check goes behind it.
recheck.addAll(batch.subList(i, batch.size()));
break;
}
Long appid = batch.get(i);
String name = names.get(appid);
if (name == null || name.equals(SteamGame.placeholderName(appid))) continue;
boolean stored;
try {
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()));
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()), parents.get().get(appid));
} catch (Exception e) {
log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage());
stored = false;
@@ -133,31 +174,78 @@ public class SpeedrunService {
} else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size());
pending.clear();
recheck.clear();
return;
}
}
}
}
private static List<Long> poll(ConcurrentSkipListSet<Long> queue, int size) {
List<Long> batch = new ArrayList<>();
Long next;
while (batch.size() < size && (next = queue.pollFirst()) != null) {
batch.add(next);
}
return batch;
}
/**
* @param parent the IGDB game this one is an edition, remaster or port of; null when there is none
* @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later
*/
private boolean checkAndStore(Long appid, String name, Set<Integer> years) {
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(name));
if (hits.isEmpty()) return false;
private boolean checkAndStore(Long appid, String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
SpeedrunApiService.Game match = null;
for (SpeedrunApiService.Game candidate : SpeedrunMatch.candidates(name, years, hits.get())) {
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(candidate.id());
for (Lookup lookup : lookups(name, years, parent)) {
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(lookup.name()));
if (hits.isEmpty()) return false;
Optional<SpeedrunApiService.Game> sure = SpeedrunMatch.sure(lookup.name(), lookup.years(), hits.get());
if (sure.isEmpty()) continue;
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(sure.get().id());
if (hasRuns.isEmpty()) return false;
if (hasRuns.get()) {
match = candidate;
if (hasRuns.get()) match = sure.get();
break;
}
}
gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now());
return true;
}
private static List<Lookup> lookups(String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
List<Lookup> lookups = new ArrayList<>();
lookups.add(new Lookup(name, years));
if (parent != null && !SpeedrunMatch.searchText(parent.name()).equalsIgnoreCase(SpeedrunMatch.searchText(name))) {
lookups.add(new Lookup(parent.name(), parent.year() == null ? Set.of() : Set.of(parent.year())));
} else {
SpeedrunMatch.withoutEditionSuffix(name).ifPresent(plain -> lookups.add(new Lookup(plain, years)));
}
return lookups;
}
private record Lookup(String name, Set<Integer> years) {}
/**
* @return library key to the IGDB game it is an edition, remaster or port of; a game that is its own same game
* has no parent. Empty when IGDB failed.
*/
private Optional<Map<Long, IgdbApiService.NameAndYear>> parentNames(List<Long> appids) {
Map<Long, List<Long>> appidsByParent = new HashMap<>();
for (GameEdition edition : gameEditionRepository.findAllById(appids)) {
if (edition.getIgdbId() != null && edition.getSameGameId() != null
&& !edition.getSameGameId().equals(edition.getIgdbId())) {
appidsByParent.computeIfAbsent(edition.getSameGameId(), id -> new ArrayList<>()).add(edition.getAppid());
}
}
if (appidsByParent.isEmpty() || !igdbApiService.isEnabled()) {
return Optional.of(Map.of());
}
Optional<Map<Long, IgdbApiService.NameAndYear>> found = igdbApiService.getNamesAndYears(appidsByParent.keySet());
if (found.isEmpty()) return Optional.empty();
Map<Long, IgdbApiService.NameAndYear> parents = new HashMap<>();
found.get().forEach((parentId, parent) ->
appidsByParent.get(parentId).forEach(appid -> parents.put(appid, parent)));
return Optional.of(parents);
}
/**
* @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is
* matched by name only.
@@ -3,7 +3,7 @@ package core_service.shared.background;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
/**
* The bean names of the two background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
* The bean names of the background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
* with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded.
*/
public final class BackgroundPools {
@@ -12,11 +12,16 @@ public final class BackgroundPools {
public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME;
/**
* The achievement runs after a sync and every speedrun refresh: they hold a thread for minutes (Steam and
* speedrun.com pace), so page views keep the default pool. Small tasks, nothing is dropped.
* The achievement runs after a sync: they hold a thread for minutes (Steam pace), so page views keep the default pool. Small tasks, nothing is dropped.
*/
public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor";
/**
* The one thread that works the speedrun queue off (speedrun.com pace, a re-check can take an hour). Other tasks
* never wait for it.
*/
public static final String SPEEDRUN = "speedrunExecutor";
private BackgroundPools() {
}
}
@@ -0,0 +1,4 @@
-- New speedrun.com matching rules (#8): every match not set by an auditor is asked again once. checked_at = epoch is
-- the marker SpeedrunService.recheckMarked queues at boot; the link stays until speedrun.com answers.
UPDATE game_speedrun s SET checked_at = 'epoch'
WHERE NOT EXISTS (SELECT 1 FROM game_correction c WHERE c.appid = s.appid AND c.field = 'SPEEDRUN');
@@ -4,6 +4,8 @@ import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -45,6 +47,27 @@ class AsyncConfigTest extends AbstractUnitTest {
assertFalse(droppedRan.get());
}
@Test
void speedrunPool_RunsOnOneNamedThread() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().speedrunExecutor();
executor.initialize();
CountDownLatch done = new CountDownLatch(2);
Set<String> threads = ConcurrentHashMap.newKeySet();
try {
for (int i = 0; i < 2; i++) {
executor.execute(() -> {
threads.add(Thread.currentThread().getName());
done.countDown();
});
}
assertTrue(done.await(5, TimeUnit.SECONDS));
} finally {
executor.shutdown();
}
assertEquals(Set.of("speedrun-1"), threads);
}
@Test
void followUpPool_KeepsEveryTask() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor();
@@ -279,6 +279,28 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5)));
}
@Test
void getNamesAndYears_MapsTheIgdbIdToNameAndYear() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("fields name,first_release_date; where id = (1025,1026,1027)")))
.andRespond(withSuccess("[{\"id\": 1025, \"name\": \" Skyrim \", \"first_release_date\": 1321833600},"
+ " {\"id\": 1026, \"name\": \"Undated\"}, {\"id\": 1027, \"name\": \" \"}, {\"id\": 9, \"name\": \"Unasked\"}]",
MediaType.APPLICATION_JSON));
assertThat(service.getNamesAndYears(List.of(1025L, 1026L, 1027L))).contains(Map.of(
1025L, new IgdbApiService.NameAndYear("Skyrim", 2011),
1026L, new IgdbApiService.NameAndYear("Undated", null)));
}
@Test
void getNamesAndYears_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.getNamesAndYears(List.of(1025L))).isEmpty();
}
@Test
void getReleaseDates_IgdbError_IsEmpty() {
expectToken();
@@ -260,7 +260,8 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
List<JsonNode> rows = allRows("TREND_MOVES", null);
assertThat(rows).extracting(r -> r.get("appid").asLong()).contains(FINISHED_TRACKED, BACK_TO_PILE);
assertThat(rows).extracting(r -> r.get("at").asString()).isSortedAccordingTo(java.util.Comparator.reverseOrder());
assertThat(rows).extracting(r -> java.time.Instant.parse(r.get("at").asString()))
.isSortedAccordingTo(java.util.Comparator.reverseOrder());
assertThat(rows).filteredOn(r -> r.get("appid").asLong() == BACK_TO_PILE)
.extracting(r -> r.get("fromStatus").asString(), r -> r.get("toStatus").asString())
.contains(org.assertj.core.groups.Tuple.tuple("PLAYING", "BACKLOG"));
@@ -1,6 +1,8 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
@@ -22,8 +24,15 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
@Autowired
private EntityManager entityManager;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
}
@@ -64,4 +73,35 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
assertThat(row.getSpeedrunGameId()).isNull();
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
}
private void list(long appid) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.saveAndFlush(new UserGame(1L, appid, 0, Instant.now(), Instant.now()));
}
@Test
void findAppidsToRecheck_ListedMarkedRowsOnly() {
gameSpeedrunRepository.writeWeblink(620L, "https://www.speedrun.com/portal", Instant.EPOCH);
gameSpeedrunRepository.upsert(220L, null, null, Instant.now());
gameSpeedrunRepository.writeWeblink(730L, null, Instant.EPOCH);
gameSpeedrunRepository.writeWeblink(440L, null, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (730, 'SPEEDRUN', 'null', 'null', 7, now())").executeUpdate();
list(620);
list(220);
list(730);
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
}
@Test
void findAppidsToRecheck_RowAnsweredOnce_IsNotPickedAgain() {
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
list(620);
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).isEmpty();
}
}
@@ -18,48 +18,95 @@ class SpeedrunMatchTest extends AbstractUnitTest {
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
@Test
void candidates_YearSuffixTellsTheRemakeApart() {
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006);
private static List<String> ids(String name, Set<Integer> years, List<SpeedrunApiService.Game> hits) {
return SpeedrunMatch.sure(name, years, hits).map(SpeedrunApiService.Game::id).stream().toList();
}
@Test
void candidates_UnknownYear_KeepsSpeedrunOrder() {
assertThat(SpeedrunMatch.candidates("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006, PREY_2017);
void sure_IgdbYearPicksTheRemake() {
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).contains(PREY_2017);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).contains(PREY_2006);
}
@Test
void candidates_OneYearOffIsStillTheSameGame() {
SpeedrunApiService.Game celeste = hit("celeste", "Celeste", 2018);
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2017), List.of(celeste))).containsExactly(celeste);
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2015), List.of(celeste))).isEmpty();
void sure_NoYearAndTwoSameNames_IsEmpty() {
assertThat(SpeedrunMatch.sure("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).isEmpty();
}
@Test
void candidates_OnlyTheSameName() {
List<SpeedrunApiService.Game> hits = List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004),
hit("d2016", "DOOM", 2016));
assertThat(SpeedrunMatch.candidates("DOOM", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("d2016");
void sure_NoYearAndOneSameName_IsThatGame() {
assertThat(ids("DOOM", Set.of(), List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004),
hit("d2016", "DOOM", 2016)))).containsExactly("d2016");
}
@Test
void candidates_AtMostTwo() {
List<SpeedrunApiService.Game> hits = List.of(hit("a", "Tetris", null), hit("b", "Tetris", null), hit("c", "Tetris", null));
assertThat(SpeedrunMatch.candidates("Tetris", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("a", "b");
void sure_TwoGamesInTheYearWindow_IsEmpty() {
assertThat(SpeedrunMatch.sure("Tetris", Set.of(2000), List.of(hit("a", "Tetris", 2000), hit("b", "Tetris", 2001)))).isEmpty();
}
@Test
void candidates_RefusesLinksOutsideSpeedrunCom() {
void sure_OneYearOff_IsTheGame() {
assertThat(ids("Celeste", Set.of(2017), List.of(hit("celeste", "Celeste", 2018)))).containsExactly("celeste");
}
@Test
void sure_YearTwoOff_IsEmpty() {
assertThat(SpeedrunMatch.sure("Celeste", Set.of(2015), List.of(hit("celeste", "Celeste", 2018)))).isEmpty();
}
@Test
void sure_HitWithoutYear_OnlyWhenItIsTheOnlySameName() {
SpeedrunApiService.Game undated = hit("prey", "Prey", null);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated))).contains(undated);
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated, hit("other", "Prey", 2006)))).isEmpty();
}
@Test
void sure_RomanNumeralsMatchDigits() {
assertThat(ids("Final Fantasy VII", Set.of(1997), List.of(hit("ff7", "Final Fantasy 7", 1997)))).containsExactly("ff7");
assertThat(SpeedrunMatch.sure("Mega Man X", Set.of(), List.of(hit("mm10", "Mega Man 10", 2010)))).isEmpty();
}
@Test
void sure_OtherNamesAreIgnored() {
assertThat(ids("Hades", Set.of(2020), List.of(hit("hades2", "Hades II", 2024), hit("hades", "Hades", 2020))))
.containsExactly("hades");
assertThat(SpeedrunMatch.sure("Hades", Set.of(2024), List.of(hit("hades2", "Hades II", 2024)))).isEmpty();
}
@Test
void sure_RefusesLinksOutsideSpeedrunCom() {
List<SpeedrunApiService.Game> hits = List.of(
new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"),
new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"),
new SpeedrunApiService.Game("c", "Celeste", 2018, null),
new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500)));
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(), hits)).isEmpty();
assertThat(SpeedrunMatch.sure("Celeste", Set.of(), hits)).isEmpty();
}
@Test
void withoutEditionSuffix_StripsKnownSuffixes() {
assertThat(SpeedrunMatch.withoutEditionSuffix("The Elder Scrolls V: Skyrim Special Edition"))
.contains("The Elder Scrolls V: Skyrim");
assertThat(SpeedrunMatch.withoutEditionSuffix("The Witcher 3: Wild Hunt - Game of the Year Edition"))
.contains("The Witcher 3: Wild Hunt");
assertThat(SpeedrunMatch.withoutEditionSuffix("Borderlands GOTY")).contains("Borderlands");
assertThat(SpeedrunMatch.withoutEditionSuffix("Death Stranding Director's Cut")).contains("Death Stranding");
assertThat(SpeedrunMatch.withoutEditionSuffix("DOOM Eternal Deluxe Edition")).contains("DOOM Eternal");
assertThat(SpeedrunMatch.withoutEditionSuffix("Mafia: Definitive Edition")).contains("Mafia");
}
@Test
void withoutEditionSuffix_StripsTheLeadingThe() {
assertThat(SpeedrunMatch.withoutEditionSuffix("Grand Theft Auto IV: The Complete Edition")).contains("Grand Theft Auto IV");
}
@Test
void withoutEditionSuffix_KeepsRemasteredAndPlainNames() {
assertThat(SpeedrunMatch.withoutEditionSuffix("Dark Souls Remastered")).isEmpty();
assertThat(SpeedrunMatch.withoutEditionSuffix("Prey")).isEmpty();
assertThat(SpeedrunMatch.withoutEditionSuffix("Gold Edition")).isEmpty();
}
}
@@ -1,7 +1,9 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameEdition;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameEditionRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -9,7 +11,7 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
@@ -29,6 +31,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -44,18 +49,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
@Mock
private GameSpeedrunRepository gameSpeedrunRepository;
@Mock
private GameEditionRepository gameEditionRepository;
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private UserGameRepository userGameRepository;
@Spy
private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor();
@InjectMocks
@Spy
private TaskExecutor speedrunExecutor = new SyncTaskExecutor();
private SpeedrunService service;
private static final String LINK = "https://www.speedrun.com/prey2017";
@BeforeEach
void setUp() {
service = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository, gameEditionRepository,
steamGameRepository, userGameRepository, syncFollowUpExecutor, speedrunExecutor);
when(speedrunApiService.isEnabled()).thenReturn(true);
}
@@ -72,6 +82,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
});
}
private void game(long appid, String name) {
when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(appid, name, null, Instant.EPOCH)));
}
private void edition(long appid, long igdbId, long sameGameId) {
GameEdition edition = mock(GameEdition.class);
lenient().when(edition.getAppid()).thenReturn(appid);
when(edition.getIgdbId()).thenReturn(igdbId);
when(edition.getSameGameId()).thenReturn(sameGameId);
when(gameEditionRepository.findAllById(any())).thenReturn(List.of(edition));
}
private void igdbYears(long appid, Integer... years) {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getReleaseYears(List.of(appid))).thenReturn(Optional.of(Map.of(appid, Set.of(years))));
}
private static SpeedrunApiService.Game hit(String id, String name, int year) {
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
}
@@ -103,18 +130,128 @@ class SpeedrunServiceTest extends AbstractUnitTest {
}
@Test
void refresh_NoGameWithRuns_StoresAnEmptyRowAfterTwoCandidates() {
void refresh_SureGameWithoutRuns_StoresNoLink() {
names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
hit("a", "Prey", 2006), hit("b", "Prey", 2006), hit("c", "Prey", 2006))));
when(speedrunApiService.hasVerifiedRuns(anyString())).thenReturn(Optional.of(false));
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey", "Prey", 2006))));
when(speedrunApiService.hasVerifiedRuns("prey")).thenReturn(Optional.of(false));
service.refresh(List.of(620L));
verify(speedrunApiService, times(2)).hasVerifiedRuns(anyString());
verify(speedrunApiService, times(1)).hasVerifiedRuns(anyString());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_SeveralSameNamesWithoutYear_StoresNoLinkWithoutAskingForRuns() {
names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
hit("prey", "Prey", 2006), hit("prey2017", "Prey (2017)", 2017))));
service.refresh(List.of(620L));
verify(speedrunApiService, never()).hasVerifiedRuns(anyString());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_EditionMisses_ParentNameHits() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("skyrim", "The Elder Scrolls V: Skyrim", 2011, "https://www.speedrun.com/skyrim"))));
when(speedrunApiService.hasVerifiedRuns("skyrim")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("skyrim"), eq("https://www.speedrun.com/skyrim"), any());
}
@Test
void refresh_NoParent_TriesTheNameWithoutEditionSuffix() {
game(620, "DOOM Eternal Deluxe Edition");
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
}
@Test
void refresh_RemakeHasNoParent_DoesNotUseTheOriginal() {
game(620, "Resident Evil 2");
igdbYears(620, 2019);
edition(620, 100, 100);
when(speedrunApiService.findGames("Resident Evil 2")).thenReturn(Optional.of(List.of(hit("re2old", "Resident Evil 2", 1998))));
service.refresh(List.of(620L));
verify(speedrunApiService, times(1)).findGames(anyString());
verify(igdbApiService, never()).getNamesAndYears(any());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_RemakeWithEditionSuffix_DoesNotGetTheOriginal() {
game(620, "Mafia: Definitive Edition");
igdbYears(620, 2020);
edition(620, 100, 100);
when(speedrunApiService.findGames("Mafia: Definitive Edition")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
lenient().when(speedrunApiService.findGames("Mafia")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
lenient().when(speedrunApiService.hasVerifiedRuns("mafia")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_EditionOfAMainGame_TriesTheNameWithoutEditionSuffix() {
game(620, "DOOM Eternal Deluxe Edition");
igdbYears(620, 2020);
edition(620, 100, 100);
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
lenient().when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
lenient().when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
}
@Test
void refresh_ParentSearchFails_StoresNothing() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_ParentNamesIgdbFailed_StoresNothing() {
game(620, "The Elder Scrolls V: Skyrim Special Edition");
igdbYears(620, 2016);
edition(620, 100, 50);
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(speedrunApiService, never()).findGames(anyString());
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_FailedCall_StoresNothing() {
names(620);
@@ -228,6 +365,59 @@ class SpeedrunServiceTest extends AbstractUnitTest {
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
}
@Test
void recheckMarked_QueuesTheMarkedGames() {
names(1, 2);
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(List.of(1L, 2L));
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of()));
service.recheckMarked();
verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
}
@Test
void recheckMarked_Disabled_DoesNothing() {
when(speedrunApiService.isEnabled()).thenReturn(false);
service.recheckMarked();
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
}
@Test
void recheckMarked_UserRequestQueuedAfterManyMarkedGames_IsServedNext() {
long[] marked = LongStream.rangeClosed(1, 30).toArray();
names(LongStream.concat(LongStream.of(999), LongStream.of(marked)).toArray());
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(LongStream.of(marked).boxed().toList());
when(speedrunApiService.findGames("Prey")).thenAnswer(call -> {
// The first marked game is being asked when a user asks for another one.
service.refresh(List.of(999L));
return Optional.of(List.of());
}).thenReturn(Optional.of(List.of()));
service.recheckMarked();
InOrder order = inOrder(gameSpeedrunRepository);
order.verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
order.verify(gameSpeedrunRepository).upsert(eq(999L), isNull(), isNull(), any());
order.verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
verify(gameSpeedrunRepository, times(31)).upsert(any(), isNull(), isNull(), any());
}
@Test
void refresh_OnlyQueuesAndLeavesTheWorkToTheSpeedrunExecutor() {
TaskExecutor waiting = mock(TaskExecutor.class);
SpeedrunService queued = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository,
gameEditionRepository, steamGameRepository, userGameRepository, syncFollowUpExecutor, waiting);
queued.refresh(List.of(620L));
verify(waiting).execute(any());
verifyNoInteractions(steamGameRepository);
}
@Test
void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() {
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of());
@@ -53,6 +53,13 @@ public abstract class AbstractIntegrationTest {
@TestBean(name = BackgroundPools.SYNC_FOLLOW_UP)
private TaskExecutor syncFollowUpExecutor;
@TestBean(name = BackgroundPools.SPEEDRUN)
private TaskExecutor speedrunExecutor;
static TaskExecutor speedrunExecutor() {
return new SyncTaskExecutor();
}
static TaskExecutor applicationTaskExecutor() {
return new SyncTaskExecutor();
}