Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e041c71cf8 | ||
|
|
df3d6d2537 |
No files matched your search
@@ -28,13 +28,15 @@ To Be Played is split into three repositories:
|
|||||||
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
||||||
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
||||||
`pileGames`, so coming games are not on it; Core never picks them.
|
`pileGames`, so coming games are not on it; Core never picks them.
|
||||||
- **What to play next** (`/play-next`): four backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
- **What to play next** (`/play-next`): six rows of up to 3 backlog games each: up next (your own backlog order),
|
||||||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
|
||||||
played a lot, waiting longest, random, or next in your own backlog order. Reroll without repeats, and "Start playing" in one click.
|
played), same studio (a developer you played), longest on your list, and mood picks. A time
|
||||||
|
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
|
||||||
|
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
|
||||||
|
On a phone each row scrolls sideways.
|
||||||
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
|
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
|
||||||
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
||||||
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker has a "next in your
|
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games.
|
||||||
backlog" reason.
|
|
||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
|
|||||||
+13
-5
@@ -431,12 +431,21 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "Was spiele ich als Nächstes?",
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
"SUBTITLE": "Vier Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende, eins zufällig und eins von ganz oben in deiner Reihenfolge. Wähle eine Länge und eine Stimmung.",
|
"SUBTITLE": "Spiele aus deinem Backlog in sechs Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||||
"LONGEST": "Auf deiner Liste seit {{date}}",
|
"SERIES": "Reihe: {{name}}",
|
||||||
|
"STUDIO": "Von {{name}}",
|
||||||
|
"LONGEST": "Auf deiner Liste seit {{date}}"
|
||||||
|
},
|
||||||
|
"CATEGORY": {
|
||||||
|
"NEXT_UP": "Als Nächstes",
|
||||||
|
"SERIES": "Nächster Teil der Reihe",
|
||||||
|
"SIMILAR": "Weil du gespielt hast",
|
||||||
|
"STUDIO": "Vom selben Studio",
|
||||||
|
"LONGEST": "Am längsten auf deiner Liste",
|
||||||
"RANDOM": "Zufallstreffer",
|
"RANDOM": "Zufallstreffer",
|
||||||
"NEXT_UP": "Als Nächstes in deinem Backlog"
|
"MOOD": "Stimmung: {{mood}}"
|
||||||
},
|
},
|
||||||
"BUDGET_LABEL": "Spieldauer",
|
"BUDGET_LABEL": "Spieldauer",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
@@ -447,10 +456,9 @@
|
|||||||
},
|
},
|
||||||
"BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).",
|
"BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).",
|
||||||
"REROLL": "Andere Vorschläge",
|
"REROLL": "Andere Vorschläge",
|
||||||
|
"REROLL_ROW": "Andere Vorschläge für {{category}}",
|
||||||
"START_PLAYING": "Jetzt spielen",
|
"START_PLAYING": "Jetzt spielen",
|
||||||
"STARTED": "Wird gespielt",
|
"STARTED": "Wird gespielt",
|
||||||
"ALL_SHOWN": "Du hast alle passenden Spiele gesehen.",
|
|
||||||
"START_OVER": "Von vorn",
|
|
||||||
"EMPTY": "Dein Backlog ist leer. Stark!",
|
"EMPTY": "Dein Backlog ist leer. Stark!",
|
||||||
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
|
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
|
||||||
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
|
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
|
||||||
|
|||||||
+14
-6
@@ -431,12 +431,21 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "What to play next",
|
"TITLE": "What to play next",
|
||||||
"SUBTITLE": "Four games from your backlog: one like a game you played a lot, the one waiting longest, one at random, and one from the top of your own order. Pick a length and a mood.",
|
"SUBTITLE": "Games from your backlog in six rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||||
"LONGEST": "On your list since {{date}}",
|
"SERIES": "Series: {{name}}",
|
||||||
"RANDOM": "Random pick",
|
"STUDIO": "From {{name}}",
|
||||||
"NEXT_UP": "Next in your backlog"
|
"LONGEST": "On your list since {{date}}"
|
||||||
|
},
|
||||||
|
"CATEGORY": {
|
||||||
|
"NEXT_UP": "Up next",
|
||||||
|
"SERIES": "Next in series",
|
||||||
|
"SIMILAR": "Because you played",
|
||||||
|
"STUDIO": "Same studio",
|
||||||
|
"LONGEST": "Longest on your list",
|
||||||
|
"RANDOM": "Random picks",
|
||||||
|
"MOOD": "Mood: {{mood}}"
|
||||||
},
|
},
|
||||||
"BUDGET_LABEL": "Time to beat",
|
"BUDGET_LABEL": "Time to beat",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
@@ -447,10 +456,9 @@
|
|||||||
},
|
},
|
||||||
"BUDGET_HINT": "Only games with a known time to beat (from IGDB).",
|
"BUDGET_HINT": "Only games with a known time to beat (from IGDB).",
|
||||||
"REROLL": "Other picks",
|
"REROLL": "Other picks",
|
||||||
|
"REROLL_ROW": "Other picks for {{category}}",
|
||||||
"START_PLAYING": "Start playing",
|
"START_PLAYING": "Start playing",
|
||||||
"STARTED": "Now playing",
|
"STARTED": "Now playing",
|
||||||
"ALL_SHOWN": "You have seen every game that fits.",
|
|
||||||
"START_OVER": "Start over",
|
|
||||||
"EMPTY": "Your backlog is empty. Nice!",
|
"EMPTY": "Your backlog is empty. Nice!",
|
||||||
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
|
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
|
||||||
"ERROR_LOAD": "Could not load the picks.",
|
"ERROR_LOAD": "Could not load the picks.",
|
||||||
|
|||||||
+142
-67
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -15,7 +15,6 @@ import {saveGameStatus} from '../utils/backlog.js';
|
|||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {request} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
|
||||||
|
|
||||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||||
const BUDGETS = [
|
const BUDGETS = [
|
||||||
@@ -27,67 +26,126 @@ const BUDGETS = [
|
|||||||
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
|
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
|
||||||
// games without that data.
|
// games without that data.
|
||||||
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
|
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
|
||||||
const PICK_COUNT = 4;
|
// The rows in the order Core sends them; RANDOM is the mood row.
|
||||||
|
const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'LONGEST', 'RANDOM'];
|
||||||
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
||||||
const MAX_EXCLUDES = 100;
|
const MAX_EXCLUDES = 100;
|
||||||
|
const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible';
|
||||||
|
const CARD_CLASS = 'w-64 shrink-0 snap-start sm:w-auto';
|
||||||
|
|
||||||
const pickerQuery = (maxMinutes, mood, exclude) => {
|
// The mood only filters the RANDOM row.
|
||||||
const params = new URLSearchParams({count: String(PICK_COUNT)});
|
const pickerQuery = (maxMinutes, mood, exclude, category) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (category) params.set('category', category);
|
||||||
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
||||||
if (mood !== 'ANY') params.set('mood', mood);
|
if (mood !== 'ANY' && (!category || category === 'RANDOM')) params.set('mood', mood);
|
||||||
exclude.forEach((appid) => params.append('exclude', String(appid)));
|
exclude.forEach((appid) => params.append('exclude', String(appid)));
|
||||||
return params.toString();
|
return params.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPicker = (maxMinutes, mood, exclude, category, signal) => request(
|
||||||
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, mood, exclude, category)}`,
|
||||||
|
{signal});
|
||||||
|
|
||||||
export default function PlayNextPage() {
|
export default function PlayNextPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||||
const [mood, setMood] = useState('ANY');
|
const [mood, setMood] = useState('ANY');
|
||||||
const [games, setGames] = useState(null);
|
const [rows, setRows] = useState(null);
|
||||||
const [seen, setSeen] = useState([]);
|
const [seen, setSeen] = useState({});
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
// Keys ('ALL' or a row) with a request in flight.
|
||||||
// A slow answer for an older budget or mood must not replace the newer one.
|
const [loading, setLoading] = useState(() => new Set());
|
||||||
const nextSignal = useAbortPrevious();
|
// One controller per request key ('ALL' or a row). A slow answer for an older budget, mood or reroll must not
|
||||||
|
// replace the newer one.
|
||||||
|
const controllers = useRef(new Map());
|
||||||
|
|
||||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
useEffect(() => {
|
||||||
const signal = nextSignal();
|
const current = controllers.current;
|
||||||
setLoading(true);
|
return () => current.forEach((controller) => controller.abort());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLoading = useCallback((key) => setLoading((old) => new Set(old).add(key)), []);
|
||||||
|
const finishLoading = useCallback((key) => setLoading((old) => {
|
||||||
|
const next = new Set(old);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const nextSignal = useCallback((key) => {
|
||||||
|
controllers.current.get(key)?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllers.current.set(key, controller);
|
||||||
|
return controller.signal;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAll = useCallback(async (maxMinutes, moodKey) => {
|
||||||
|
controllers.current.forEach((controller) => controller.abort());
|
||||||
|
const signal = nextSignal('ALL');
|
||||||
setError(null);
|
setError(null);
|
||||||
const {ok, status, data} = await request(
|
setLoading(new Set(['ALL']));
|
||||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
|
const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, [], null, signal);
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
setLoading(false);
|
finishLoading('ALL');
|
||||||
if (status === 401) return;
|
if (status === 401) return;
|
||||||
if (!ok || !data) {
|
if (!ok || !data) {
|
||||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const picked = data.games ?? [];
|
const categories = data.categories ?? [];
|
||||||
setGames(picked);
|
setRows(categories);
|
||||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
setSeen(Object.fromEntries(categories.map((c) => [c.source, c.games.map((g) => g.appid)])));
|
||||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
}, [nextSignal, finishLoading]);
|
||||||
}, [nextSignal]);
|
|
||||||
|
const loadRow = useCallback(async (source, exclude, maxMinutes, moodKey) => {
|
||||||
|
const signal = nextSignal(source);
|
||||||
|
startLoading(source);
|
||||||
|
const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, exclude, source, signal);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
finishLoading(source);
|
||||||
|
if (!ok || !data) {
|
||||||
|
if (status !== 401) toast.error(t(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = (data.categories ?? []).find((c) => c.source === source);
|
||||||
|
if (!row && exclude.length > 0) {
|
||||||
|
// Every game that fits was shown: start the row over.
|
||||||
|
await loadRow(source, [], maxMinutes, moodKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRows((old) => ROW_ORDER
|
||||||
|
.map((key) => (key === source ? row : old.find((r) => r.source === key)))
|
||||||
|
.filter(Boolean));
|
||||||
|
setSeen((old) => ({
|
||||||
|
...old,
|
||||||
|
[source]: [...exclude, ...(row?.games ?? []).map((g) => g.appid)].slice(-MAX_EXCLUDES),
|
||||||
|
}));
|
||||||
|
}, [nextSignal, startLoading, finishLoading, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(budget.maxMinutes, mood, []);
|
loadAll(BUDGETS[3].maxMinutes, 'ANY');
|
||||||
}, [budget, mood, load]);
|
}, [loadAll]);
|
||||||
|
|
||||||
const chooseBudget = (next) => {
|
const chooseBudget = (next) => {
|
||||||
if (next.key === budget.key) return;
|
if (next.key === budget.key) return;
|
||||||
setGames(null);
|
setRows(null);
|
||||||
setSeen([]);
|
setSeen({});
|
||||||
setBudget(next);
|
setBudget(next);
|
||||||
|
loadAll(next.maxMinutes, mood);
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseMood = (next) => {
|
const chooseMood = (next) => {
|
||||||
if (next === mood) return;
|
if (next === mood) return;
|
||||||
setGames(null);
|
|
||||||
setSeen([]);
|
|
||||||
setMood(next);
|
setMood(next);
|
||||||
|
if (rows === null) loadAll(budget.maxMinutes, next);
|
||||||
|
else loadRow('RANDOM', [], budget.maxMinutes, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const allShown = games?.length === 0 && seen.length > 0;
|
const headingFor = (source) => (source === 'RANDOM' && mood !== 'ANY'
|
||||||
|
? t('PLAY_NEXT.CATEGORY.MOOD', {mood: t(`PLAY_NEXT.MOOD.${mood}`)})
|
||||||
|
: t(`PLAY_NEXT.CATEGORY.${source}`));
|
||||||
|
const moodRowMissing = rows !== null && mood !== 'ANY' && !loading.has('RANDOM')
|
||||||
|
&& !rows.some((r) => r.source === 'RANDOM');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
@@ -134,60 +192,77 @@ export default function PlayNextPage() {
|
|||||||
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
|
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
{moodRowMissing && (
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
|
||||||
|
|
||||||
{games === null && !error && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{games?.length > 0 && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(games?.length > 0 || error) && (
|
|
||||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, seen)} disabled={loading}>
|
|
||||||
{loading ? <Loader2 className="animate-spin"/> : <Shuffle/>}
|
|
||||||
{t('PLAY_NEXT.REROLL')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{allShown && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p>
|
|
||||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, [])} disabled={loading}>
|
|
||||||
<Shuffle/>{t('PLAY_NEXT.START_OVER')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{games?.length === 0 && seen.length === 0 && (
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(emptyKey(budget, mood))}
|
{t(emptyKey(budget, mood))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-destructive">{t(error)}</p>
|
||||||
|
<Button variant="outline" onClick={() => loadAll(budget.maxMinutes, mood)}>
|
||||||
|
<Shuffle/>{t('PLAY_NEXT.REROLL')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows === null && !error && ROW_ORDER.map((source) => (
|
||||||
|
<div key={source} className={ROW_CLASS}>
|
||||||
|
{Array.from({length: 3}, (_, i) => <Skeleton key={i} className={`h-56 rounded-xl ${CARD_CLASS}`}/>)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rows?.map(({source, games}) => (
|
||||||
|
<section key={source} aria-labelledby={`row-${source}`} className="min-w-0 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 id={`row-${source}`} className="text-xl font-semibold">{headingFor(source)}</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={t('PLAY_NEXT.REROLL_ROW', {category: headingFor(source)})}
|
||||||
|
onClick={() => loadRow(source, seen[source] ?? [], budget.maxMinutes, mood)}
|
||||||
|
disabled={loading.has(source)}
|
||||||
|
>
|
||||||
|
{loading.has(source) ? <Loader2 className="animate-spin"/> : <Shuffle/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={ROW_CLASS}>
|
||||||
|
{games.map((game) => (
|
||||||
|
<div key={game.appid} className={CARD_CLASS}><PickCard game={game}/></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rows?.length === 0 && !moodRowMissing && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(emptyKey(budget, 'ANY'))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The row heading says why a NEXT_UP or RANDOM game was picked.
|
||||||
const reasonText = (reason, t, language) => {
|
const reasonText = (reason, t, language) => {
|
||||||
if (!reason) return null;
|
if (reason?.source === 'SIMILAR') {
|
||||||
if (reason.source === 'SIMILAR') {
|
|
||||||
return t('PLAY_NEXT.REASON.SIMILAR', {
|
return t('PLAY_NEXT.REASON.SIMILAR', {
|
||||||
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
|
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
|
||||||
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
|
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (reason.source === 'LONGEST') {
|
if (reason?.source === 'SERIES') {
|
||||||
|
return t('PLAY_NEXT.REASON.SERIES', {name: reason.seriesName});
|
||||||
|
}
|
||||||
|
if (reason?.source === 'STUDIO') {
|
||||||
|
return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName});
|
||||||
|
}
|
||||||
|
if (reason?.source === 'LONGEST') {
|
||||||
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
|
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
|
||||||
}
|
}
|
||||||
if (reason.source === 'NEXT_UP') return t('PLAY_NEXT.REASON.NEXT_UP');
|
return null;
|
||||||
return t('PLAY_NEXT.REASON.RANDOM');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyKey = (budget, mood) => {
|
const emptyKey = (budget, mood) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('PlayNextPage on a language change', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
i18n.t = (key) => key;
|
i18n.t = (key) => key;
|
||||||
fetchRoutes({'GET /api/library/picker': json(200, {games: [game(1)]})});
|
fetchRoutes({'GET /api/library/picker': json(200, {categories: [{source: 'RANDOM', games: [game(1)]}]})});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
+194
-103
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import PlayNextPage from './PlayNextPage.jsx';
|
import PlayNextPage from './PlayNextPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
@@ -14,21 +15,46 @@ const game = (appid, extra = {}) => ({
|
|||||||
timeToBeatMinutes: 600, ...extra,
|
timeToBeatMinutes: 600, ...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Answers each picker call with the next list.
|
const row = (source, ...games) => ({source, games});
|
||||||
function mockBackend(pickerAnswers, routes = {}) {
|
const ALL = () => [
|
||||||
const answers = [...pickerAnswers];
|
row('NEXT_UP', game(1), game(2)),
|
||||||
|
row('SERIES', game(6, {reason: {source: 'SERIES', seriesName: 'Mass Effect'}})),
|
||||||
|
row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})),
|
||||||
|
row('STUDIO', game(8, {reason: {source: 'STUDIO', studioName: 'BioWare'}})),
|
||||||
|
row('LONGEST', game(4, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}})),
|
||||||
|
row('RANDOM', game(5, {reason: {source: 'RANDOM'}})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// `answer(params)` gives the categories for one picker call.
|
||||||
|
function mockBackend(answer, routes = {}) {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/library/picker': () => json(200, {games: answers.shift() ?? []}),
|
'GET /api/library/picker': (url) => json(200, {categories: answer(new URL(url, 'http://x').searchParams)}),
|
||||||
'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
|
'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)),
|
||||||
...routes,
|
...routes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All rows on a full load; one row on a reroll, from the next answer of that row.
|
||||||
|
function mockRows(initial = ALL(), rerolls = {}) {
|
||||||
|
const queues = Object.fromEntries(Object.entries(rerolls).map(([source, answers]) => [source, [...answers]]));
|
||||||
|
mockBackend((params) => {
|
||||||
|
const category = params.get('category');
|
||||||
|
if (!category) return initial;
|
||||||
|
const answer = queues[category]?.shift();
|
||||||
|
return answer ? [answer] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const pickerUrls = () => globalThis.fetch.mock.calls
|
const pickerUrls = () => globalThis.fetch.mock.calls
|
||||||
.map(([url]) => url)
|
.map(([url]) => url)
|
||||||
.filter((url) => url.includes('/api/library/picker'))
|
.filter((url) => url.includes('/api/library/picker'))
|
||||||
.map((url) => new URL(url, 'http://x').searchParams);
|
.map((url) => new URL(url, 'http://x').searchParams);
|
||||||
|
|
||||||
|
const rerollOf = (heading) => {
|
||||||
|
const section = screen.getByRole('heading', {name: heading}).closest('section');
|
||||||
|
return section.querySelector('button[aria-label="PLAY_NEXT.REROLL_ROW"]');
|
||||||
|
};
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
render(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
render(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||||
}
|
}
|
||||||
@@ -41,73 +67,148 @@ describe('PlayNextPage', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows four picks for any length by default', async () => {
|
it('shows the rows in order with their headings, on the first load without category', async () => {
|
||||||
mockBackend([[game(1), game(2), game(3)]]);
|
mockRows();
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
await screen.findByText('Game 1');
|
||||||
expect(screen.getByText('Game 3')).toBeTruthy();
|
expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([
|
||||||
|
'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SERIES', 'PLAY_NEXT.CATEGORY.SIMILAR',
|
||||||
|
'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']);
|
||||||
const params = pickerUrls()[0];
|
const params = pickerUrls()[0];
|
||||||
expect(params.get('count')).toBe('4');
|
expect(params.has('count')).toBe(false);
|
||||||
|
expect(params.has('category')).toBe(false);
|
||||||
expect(params.has('maxMinutes')).toBe(false);
|
expect(params.has('maxMinutes')).toBe(false);
|
||||||
expect(params.has('exclude')).toBe(false);
|
expect(params.has('mood')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('says why a game from the top of the backlog order was picked', async () => {
|
it('hides a row the backend leaves out', async () => {
|
||||||
mockBackend([[game(1, {reason: {source: 'NEXT_UP'}})]]);
|
mockRows([row('NEXT_UP', game(1)), row('RANDOM', game(5))]);
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.REASON.NEXT_UP')).toBeTruthy();
|
await screen.findByText('Game 1');
|
||||||
|
expect(screen.queryByRole('heading', {name: 'PLAY_NEXT.CATEGORY.SIMILAR'})).toBeNull();
|
||||||
|
expect(screen.queryByRole('heading', {name: 'PLAY_NEXT.CATEGORY.LONGEST'})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks for games up to the chosen length', async () => {
|
it('says so when every row is empty', async () => {
|
||||||
mockBackend([[game(1)], [game(2)]]);
|
mockRows([]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.EMPTY')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('heading', {level: 2})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads all rows for the chosen length', async () => {
|
||||||
|
mockBackend((params) => (params.get('maxMinutes') ? [row('NEXT_UP', game(9))] : ALL()));
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
||||||
|
|
||||||
expect(await screen.findByText('Game 2')).toBeTruthy();
|
expect(await screen.findByText('Game 9')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Game 5')).toBeNull();
|
||||||
|
expect(pickerUrls()).toHaveLength(2);
|
||||||
expect(pickerUrls()[1].get('maxMinutes')).toBe('300');
|
expect(pickerUrls()[1].get('maxMinutes')).toBe('300');
|
||||||
expect(pickerUrls()[1].has('exclude')).toBe(false);
|
expect(pickerUrls()[1].has('category')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks for games of the chosen mood and keeps the length', async () => {
|
it('reloads only the mood row for a mood and keeps the others', async () => {
|
||||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
mockRows(ALL(), {RANDOM: [row('RANDOM', game(6))]});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
|
||||||
await screen.findByText('Game 2');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
|
||||||
|
|
||||||
expect(await screen.findByText('Game 3')).toBeTruthy();
|
expect(await screen.findByText('Game 6')).toBeTruthy();
|
||||||
expect(pickerUrls()[2].get('mood')).toBe('COOP');
|
expect(pickerUrls()).toHaveLength(2);
|
||||||
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
|
expect(pickerUrls()[1].get('category')).toBe('RANDOM');
|
||||||
expect(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}).getAttribute('aria-pressed')).toBe('true');
|
expect(pickerUrls()[1].get('mood')).toBe('COOP');
|
||||||
|
expect(screen.getByText('Game 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Game 4')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Game 5')).toBeNull();
|
||||||
|
expect(screen.getByRole('heading', {name: 'PLAY_NEXT.CATEGORY.MOOD'})).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends no mood for any mood and rerolls within the mood', async () => {
|
it('keeps the length when the mood changes', async () => {
|
||||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
mockRows(ALL(), {RANDOM: [row('RANDOM', game(6))]});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
expect(pickerUrls()[0].has('mood')).toBe(false);
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
|
||||||
await screen.findByText('Game 2');
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
|
||||||
|
|
||||||
await screen.findByText('Game 3');
|
await screen.findByText('Game 6');
|
||||||
expect(pickerUrls()[2].get('mood')).toBe('STORY');
|
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
|
||||||
expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suggests another mood when none of the backlog fits', async () => {
|
it('rerolls one row without the games it showed and changes only that row', async () => {
|
||||||
mockBackend([[game(1)], []]);
|
mockRows(ALL(), {NEXT_UP: [row('NEXT_UP', game(10), game(11))]});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP'));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 10')).toBeTruthy();
|
||||||
|
expect(pickerUrls()[1].get('category')).toBe('NEXT_UP');
|
||||||
|
expect(pickerUrls()[1].getAll('exclude')).toEqual(['1', '2']);
|
||||||
|
expect(screen.queryByText('Game 1')).toBeNull();
|
||||||
|
expect(screen.getByText('Game 3')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Game 5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no mood when a row other than the mood row is rerolled', async () => {
|
||||||
|
mockRows(ALL(), {LONGEST: [row('LONGEST', game(10))]});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
|
||||||
|
await screen.findByText('Game 5');
|
||||||
|
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.LONGEST'));
|
||||||
|
|
||||||
|
await screen.findByText('Game 10');
|
||||||
|
expect(pickerUrls()[2].has('mood')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rerolls the series row with category=SERIES and without a mood', async () => {
|
||||||
|
mockRows(ALL(), {SERIES: [row('SERIES', game(10))]});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
|
||||||
|
await screen.findByText('Game 5');
|
||||||
|
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.SERIES'));
|
||||||
|
|
||||||
|
await screen.findByText('Game 10');
|
||||||
|
expect(pickerUrls()[2].get('category')).toBe('SERIES');
|
||||||
|
expect(pickerUrls()[2].has('mood')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a row over when it has no unseen game left', async () => {
|
||||||
|
mockBackend((params) => {
|
||||||
|
if (!params.get('category')) return ALL();
|
||||||
|
return params.has('exclude') ? [] : [row('LONGEST', game(4))];
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.LONGEST'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pickerUrls()).toHaveLength(3));
|
||||||
|
expect(pickerUrls()[1].getAll('exclude')).toEqual(['4']);
|
||||||
|
expect(pickerUrls()[2].has('exclude')).toBe(false);
|
||||||
|
expect(screen.getByText('Game 4')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the mood text under the mood buttons when the mood row is empty', async () => {
|
||||||
|
mockRows(ALL(), {});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
@@ -115,10 +216,12 @@ describe('PlayNextPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
||||||
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
|
expect(screen.getByText('PLAY_NEXT.MOOD_HINT')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Game 5')).toBeNull();
|
||||||
|
expect(screen.getByText('Game 1')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asks for speedrun games and explains the background check', async () => {
|
it('explains the background check for speedrun without a length limit', async () => {
|
||||||
mockBackend([[game(1)], []]);
|
mockRows(ALL(), {});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
@@ -129,21 +232,33 @@ describe('PlayNextPage', () => {
|
|||||||
expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy();
|
expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with a length limit, an empty speedrun pick says to change the length', async () => {
|
it('with a length limit, an empty speedrun row says to change the length', async () => {
|
||||||
mockBackend([[game(1)], [game(2)], []]);
|
mockRows(ALL(), {});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
||||||
await screen.findByText('Game 2');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'}));
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'}));
|
||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the old picks when a new budget fails to load', async () => {
|
it('shows a reason line for the series, similar, studio and longest rows only', async () => {
|
||||||
mockBackend([], {'GET /api/library/picker': (url) =>
|
mockRows();
|
||||||
(url.includes('maxMinutes=300') ? json(500, {}) : json(200, {games: [game(1)]}))});
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.SERIES')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.STUDIO')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/PLAY_NEXT\.REASON\.(RANDOM|NEXT_UP)/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the old rows when a new length fails to load and retries on click', async () => {
|
||||||
|
mockBackend(() => ALL(), {'GET /api/library/picker': (url) =>
|
||||||
|
(url.includes('maxMinutes=300') ? json(500, {}) : json(200, {categories: ALL()}))});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
@@ -151,42 +266,32 @@ describe('PlayNextPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.ERROR_LOAD')).toBeTruthy();
|
expect(await screen.findByText('PLAY_NEXT.ERROR_LOAD')).toBeTruthy();
|
||||||
expect(screen.queryByText('Game 1')).toBeNull();
|
expect(screen.queryByText('Game 1')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||||
|
await waitFor(() => expect(pickerUrls()).toHaveLength(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rerolls without the games already shown', async () => {
|
it('offers a retry when the first load fails', async () => {
|
||||||
mockBackend([[game(1), game(2)], [game(3)]]);
|
mockBackend(() => [], {'GET /api/library/picker': () => { throw new TypeError('Failed to fetch'); }});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('INFO.ERROR.NETWORK_FAILURE');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row and shows a toast when a row reroll fails', async () => {
|
||||||
|
fetchRoutes({'GET /api/library/picker': (url) =>
|
||||||
|
(url.includes('category=') ? json(500, {}) : json(200, {categories: ALL()}))});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP'));
|
||||||
|
|
||||||
expect(await screen.findByText('Game 3')).toBeTruthy();
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('PLAY_NEXT.ERROR_LOAD'));
|
||||||
expect(pickerUrls()[1].getAll('exclude')).toEqual(['1', '2']);
|
expect(screen.getByText('Game 1')).toBeTruthy();
|
||||||
});
|
|
||||||
|
|
||||||
it('offers to start over when every game was shown', async () => {
|
|
||||||
mockBackend([[game(1)], [], [game(1)]]);
|
|
||||||
renderPage();
|
|
||||||
await screen.findByText('Game 1');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'PLAY_NEXT.START_OVER'}));
|
|
||||||
|
|
||||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
|
||||||
expect(pickerUrls()[2].has('exclude')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says so when the backlog is empty', async () => {
|
|
||||||
mockBackend([[]]);
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.EMPTY')).toBeTruthy();
|
|
||||||
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts playing with a status-only update', async () => {
|
it('starts playing with a status-only update', async () => {
|
||||||
mockBackend([[game(7, {priority: 'HIGH'})]]);
|
mockRows([row('NEXT_UP', game(7, {priority: 'HIGH'}))]);
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 7');
|
await screen.findByText('Game 7');
|
||||||
|
|
||||||
@@ -200,7 +305,7 @@ describe('PlayNextPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the user try again when saving fails', async () => {
|
it('lets the user try again when saving fails', async () => {
|
||||||
mockBackend([[game(7)]], {'PUT /api/library/games/7/status': json(500, {})});
|
mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 7');
|
await screen.findByText('Game 7');
|
||||||
|
|
||||||
@@ -210,14 +315,15 @@ describe('PlayNextPage', () => {
|
|||||||
expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull();
|
expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never sends more excludes than the backend reads', async () => {
|
it('never sends more excludes per row than the backend reads', async () => {
|
||||||
const answers = Array.from({length: 40}, (_, round) => [game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)]);
|
const answers = Array.from({length: 40}, (_, round) =>
|
||||||
mockBackend(answers);
|
row('NEXT_UP', game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)));
|
||||||
|
mockRows([answers[0]], {NEXT_UP: answers.slice(1)});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
for (let round = 1; round < 40; round++) {
|
for (let round = 1; round < 40; round++) {
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP'));
|
||||||
await screen.findByText(`Game ${round * 3 + 1}`);
|
await screen.findByText(`Game ${round * 3 + 1}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,34 +331,19 @@ describe('PlayNextPage', () => {
|
|||||||
expect(largest).toBeLessThanOrEqual(100);
|
expect(largest).toBeLessThanOrEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers a retry when the first load fails', async () => {
|
it('rowKeepsItsSpinnerWhileAnotherRowFinishesFirst', async () => {
|
||||||
mockBackend([], {'GET /api/library/picker': () => { throw new TypeError('Failed to fetch'); }});
|
fetchRoutes({'GET /api/library/picker': (url) => {
|
||||||
|
const params = new URL(url, 'http://x').searchParams;
|
||||||
|
if (params.get('category') === 'NEXT_UP') return new Promise(() => {});
|
||||||
|
return json(200, {categories: params.get('category') ? [row('LONGEST', game(10))] : ALL()});
|
||||||
|
}});
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
|
|
||||||
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows why each game was picked', async () => {
|
|
||||||
mockBackend([[
|
|
||||||
game(1, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}}),
|
|
||||||
game(2, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}}),
|
|
||||||
game(3, {reason: {source: 'RANDOM'}}),
|
|
||||||
]]);
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
|
|
||||||
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
|
|
||||||
expect(screen.getByText('PLAY_NEXT.REASON.RANDOM')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows no reason line for a game without a reason', async () => {
|
|
||||||
mockBackend([[game(1)]]);
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await screen.findByText('Game 1');
|
await screen.findByText('Game 1');
|
||||||
expect(screen.queryByText(/PLAY_NEXT\.REASON/)).toBeNull();
|
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP'));
|
||||||
|
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.LONGEST'));
|
||||||
|
await screen.findByText('Game 10');
|
||||||
|
|
||||||
|
expect(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP').disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user