Public Access
feat(play-next): show picks as category rows with reroll per row
Four rows: up next, because you played, longest on list, mood. Each row rerolls alone and tracks its own loading state. Mood change reloads only the mood row. Phones scroll each row sideways; wider screens show 3 columns.
This commit is contained in:
1 parent
43c4862606
commit
df3d6d2537
6 files changed
+338
-187
No files matched your search
@@ -28,13 +28,14 @@ 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
|
||||
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.
|
||||
- **What to play next** (`/play-next`): four backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
||||
played a lot, waiting longest, random, or next in your own backlog order. Reroll without repeats, and "Start playing" in one click.
|
||||
- **What to play next** (`/play-next`): four rows of up to 3 backlog games each: up next (your own backlog order),
|
||||
because you played (a similar game for each of your 3 most 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
|
||||
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
|
||||
backlog" reason.
|
||||
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games.
|
||||
- **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;
|
||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||
|
||||
+9
-5
@@ -431,12 +431,17 @@
|
||||
},
|
||||
"PLAY_NEXT": {
|
||||
"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 vier Reihen: als Nächstes in deiner Reihenfolge, wie Spiele, die du viel 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": {
|
||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||
"LONGEST": "Auf deiner Liste seit {{date}}",
|
||||
"LONGEST": "Auf deiner Liste seit {{date}}"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"NEXT_UP": "Als Nächstes",
|
||||
"SIMILAR": "Weil du gespielt hast",
|
||||
"LONGEST": "Am längsten auf deiner Liste",
|
||||
"RANDOM": "Zufallstreffer",
|
||||
"NEXT_UP": "Als Nächstes in deinem Backlog"
|
||||
"MOOD": "Stimmung: {{mood}}"
|
||||
},
|
||||
"BUDGET_LABEL": "Spieldauer",
|
||||
"BUDGET": {
|
||||
@@ -447,10 +452,9 @@
|
||||
},
|
||||
"BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).",
|
||||
"REROLL": "Andere Vorschläge",
|
||||
"REROLL_ROW": "Andere Vorschläge für {{category}}",
|
||||
"START_PLAYING": "Jetzt spielen",
|
||||
"STARTED": "Wird gespielt",
|
||||
"ALL_SHOWN": "Du hast alle passenden Spiele gesehen.",
|
||||
"START_OVER": "Von vorn",
|
||||
"EMPTY": "Dein Backlog ist leer. Stark!",
|
||||
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
|
||||
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden.",
|
||||
|
||||
+10
-6
@@ -431,12 +431,17 @@
|
||||
},
|
||||
"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 four rows: next in your order, like games you played a lot, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
|
||||
"REASON": {
|
||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||
"LONGEST": "On your list since {{date}}",
|
||||
"RANDOM": "Random pick",
|
||||
"NEXT_UP": "Next in your backlog"
|
||||
"LONGEST": "On your list since {{date}}"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"NEXT_UP": "Up next",
|
||||
"SIMILAR": "Because you played",
|
||||
"LONGEST": "Longest on your list",
|
||||
"RANDOM": "Random picks",
|
||||
"MOOD": "Mood: {{mood}}"
|
||||
},
|
||||
"BUDGET_LABEL": "Time to beat",
|
||||
"BUDGET": {
|
||||
@@ -447,10 +452,9 @@
|
||||
},
|
||||
"BUDGET_HINT": "Only games with a known time to beat (from IGDB).",
|
||||
"REROLL": "Other picks",
|
||||
"REROLL_ROW": "Other picks for {{category}}",
|
||||
"START_PLAYING": "Start playing",
|
||||
"STARTED": "Now playing",
|
||||
"ALL_SHOWN": "You have seen every game that fits.",
|
||||
"START_OVER": "Start over",
|
||||
"EMPTY": "Your backlog is empty. Nice!",
|
||||
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
|
||||
"ERROR_LOAD": "Could not load the picks.",
|
||||
|
||||
+136
-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 {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
@@ -15,7 +15,6 @@ import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {formatPlaytime} from '../utils/games.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.
|
||||
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
|
||||
// games without that data.
|
||||
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', 'SIMILAR', 'LONGEST', 'RANDOM'];
|
||||
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
||||
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) => {
|
||||
const params = new URLSearchParams({count: String(PICK_COUNT)});
|
||||
// The mood only filters the RANDOM row.
|
||||
const pickerQuery = (maxMinutes, mood, exclude, category) => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set('category', category);
|
||||
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)));
|
||||
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() {
|
||||
const {t} = useTranslation();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [mood, setMood] = useState('ANY');
|
||||
const [games, setGames] = useState(null);
|
||||
const [seen, setSeen] = useState([]);
|
||||
const [rows, setRows] = useState(null);
|
||||
const [seen, setSeen] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// A slow answer for an older budget or mood must not replace the newer one.
|
||||
const nextSignal = useAbortPrevious();
|
||||
// Keys ('ALL' or a row) with a request in flight.
|
||||
const [loading, setLoading] = useState(() => new Set());
|
||||
// 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) => {
|
||||
const signal = nextSignal();
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
const current = controllers.current;
|
||||
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);
|
||||
const {ok, status, data} = await request(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
|
||||
setLoading(new Set(['ALL']));
|
||||
const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, [], null, signal);
|
||||
if (signal.aborted) return;
|
||||
setLoading(false);
|
||||
finishLoading('ALL');
|
||||
if (status === 401) return;
|
||||
if (!ok || !data) {
|
||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
const picked = data.games ?? [];
|
||||
setGames(picked);
|
||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||
}, [nextSignal]);
|
||||
const categories = data.categories ?? [];
|
||||
setRows(categories);
|
||||
setSeen(Object.fromEntries(categories.map((c) => [c.source, c.games.map((g) => g.appid)])));
|
||||
}, [nextSignal, finishLoading]);
|
||||
|
||||
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(() => {
|
||||
load(budget.maxMinutes, mood, []);
|
||||
}, [budget, mood, load]);
|
||||
loadAll(BUDGETS[3].maxMinutes, 'ANY');
|
||||
}, [loadAll]);
|
||||
|
||||
const chooseBudget = (next) => {
|
||||
if (next.key === budget.key) return;
|
||||
setGames(null);
|
||||
setSeen([]);
|
||||
setRows(null);
|
||||
setSeen({});
|
||||
setBudget(next);
|
||||
loadAll(next.maxMinutes, mood);
|
||||
};
|
||||
|
||||
const chooseMood = (next) => {
|
||||
if (next === mood) return;
|
||||
setGames(null);
|
||||
setSeen([]);
|
||||
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 (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
@@ -134,60 +192,71 @@ export default function PlayNextPage() {
|
||||
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
{moodRowMissing && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(emptyKey(budget, mood))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// The row heading says why a NEXT_UP or RANDOM game was picked.
|
||||
const reasonText = (reason, t, language) => {
|
||||
if (!reason) return null;
|
||||
if (reason.source === 'SIMILAR') {
|
||||
if (reason?.source === 'SIMILAR') {
|
||||
return t('PLAY_NEXT.REASON.SIMILAR', {
|
||||
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
|
||||
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
|
||||
});
|
||||
}
|
||||
if (reason.source === 'LONGEST') {
|
||||
if (reason?.source === 'LONGEST') {
|
||||
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 t('PLAY_NEXT.REASON.RANDOM');
|
||||
return null;
|
||||
};
|
||||
|
||||
const emptyKey = (budget, mood) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PlayNextPage on a language change', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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(() => {
|
||||
|
||||
+176
-103
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import PlayNextPage from './PlayNextPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -14,21 +15,44 @@ const game = (appid, extra = {}) => ({
|
||||
timeToBeatMinutes: 600, ...extra,
|
||||
});
|
||||
|
||||
// Answers each picker call with the next list.
|
||||
function mockBackend(pickerAnswers, routes = {}) {
|
||||
const answers = [...pickerAnswers];
|
||||
const row = (source, ...games) => ({source, games});
|
||||
const ALL = () => [
|
||||
row('NEXT_UP', game(1), game(2)),
|
||||
row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})),
|
||||
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({
|
||||
'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)),
|
||||
...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
|
||||
.map(([url]) => url)
|
||||
.filter((url) => url.includes('/api/library/picker'))
|
||||
.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() {
|
||||
render(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
@@ -41,73 +65,134 @@ describe('PlayNextPage', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows four picks for any length by default', async () => {
|
||||
mockBackend([[game(1), game(2), game(3)]]);
|
||||
it('shows the rows in order with their headings, on the first load without category', async () => {
|
||||
mockRows();
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||
expect(screen.getByText('Game 3')).toBeTruthy();
|
||||
await screen.findByText('Game 1');
|
||||
expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([
|
||||
'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SIMILAR', 'PLAY_NEXT.CATEGORY.LONGEST',
|
||||
'PLAY_NEXT.CATEGORY.RANDOM']);
|
||||
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('exclude')).toBe(false);
|
||||
expect(params.has('mood')).toBe(false);
|
||||
});
|
||||
|
||||
it('says why a game from the top of the backlog order was picked', async () => {
|
||||
mockBackend([[game(1, {reason: {source: 'NEXT_UP'}})]]);
|
||||
it('hides a row the backend leaves out', async () => {
|
||||
mockRows([row('NEXT_UP', game(1)), row('RANDOM', game(5))]);
|
||||
|
||||
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 () => {
|
||||
mockBackend([[game(1)], [game(2)]]);
|
||||
it('says so when every row is empty', async () => {
|
||||
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();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
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].has('exclude')).toBe(false);
|
||||
expect(pickerUrls()[1].has('category')).toBe(false);
|
||||
});
|
||||
|
||||
it('asks for games of the chosen mood and keeps the length', async () => {
|
||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
||||
it('reloads only the mood row for a mood and keeps the others', async () => {
|
||||
mockRows(ALL(), {RANDOM: [row('RANDOM', game(6))]});
|
||||
renderPage();
|
||||
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'}));
|
||||
|
||||
expect(await screen.findByText('Game 3')).toBeTruthy();
|
||||
expect(pickerUrls()[2].get('mood')).toBe('COOP');
|
||||
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
|
||||
expect(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(await screen.findByText('Game 6')).toBeTruthy();
|
||||
expect(pickerUrls()).toHaveLength(2);
|
||||
expect(pickerUrls()[1].get('category')).toBe('RANDOM');
|
||||
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 () => {
|
||||
mockBackend([[game(1)], [game(2)], [game(3)]]);
|
||||
it('keeps the length when the mood changes', async () => {
|
||||
mockRows(ALL(), {RANDOM: [row('RANDOM', game(6))]});
|
||||
renderPage();
|
||||
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'}));
|
||||
await screen.findByText('Game 2');
|
||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
|
||||
|
||||
await screen.findByText('Game 3');
|
||||
expect(pickerUrls()[2].get('mood')).toBe('STORY');
|
||||
expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']);
|
||||
await screen.findByText('Game 6');
|
||||
expect(pickerUrls()[2].get('maxMinutes')).toBe('300');
|
||||
});
|
||||
|
||||
it('suggests another mood when none of the backlog fits', async () => {
|
||||
mockBackend([[game(1)], []]);
|
||||
it('rerolls one row without the games it showed and changes only that row', async () => {
|
||||
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('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();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
@@ -115,10 +200,12 @@ describe('PlayNextPage', () => {
|
||||
|
||||
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).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 () => {
|
||||
mockBackend([[game(1)], []]);
|
||||
it('explains the background check for speedrun without a length limit', async () => {
|
||||
mockRows(ALL(), {});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
@@ -129,21 +216,31 @@ describe('PlayNextPage', () => {
|
||||
expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('with a length limit, an empty speedrun pick says to change the length', async () => {
|
||||
mockBackend([[game(1)], [game(2)], []]);
|
||||
it('with a length limit, an empty speedrun row says to change the length', async () => {
|
||||
mockRows(ALL(), {});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
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'}));
|
||||
|
||||
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops the old picks when a new budget fails to load', async () => {
|
||||
mockBackend([], {'GET /api/library/picker': (url) =>
|
||||
(url.includes('maxMinutes=300') ? json(500, {}) : json(200, {games: [game(1)]}))});
|
||||
it('shows a reason line for the similar and longest rows only', async () => {
|
||||
mockRows();
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).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();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
@@ -151,42 +248,32 @@ describe('PlayNextPage', () => {
|
||||
|
||||
expect(await screen.findByText('PLAY_NEXT.ERROR_LOAD')).toBeTruthy();
|
||||
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 () => {
|
||||
mockBackend([[game(1), game(2)], [game(3)]]);
|
||||
it('offers a retry when the first load fails', async () => {
|
||||
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();
|
||||
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();
|
||||
expect(pickerUrls()[1].getAll('exclude')).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
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();
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('PLAY_NEXT.ERROR_LOAD'));
|
||||
expect(screen.getByText('Game 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts playing with a status-only update', async () => {
|
||||
mockBackend([[game(7, {priority: 'HIGH'})]]);
|
||||
mockRows([row('NEXT_UP', game(7, {priority: 'HIGH'}))]);
|
||||
renderPage();
|
||||
await screen.findByText('Game 7');
|
||||
|
||||
@@ -200,7 +287,7 @@ describe('PlayNextPage', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
await screen.findByText('Game 7');
|
||||
|
||||
@@ -210,14 +297,15 @@ describe('PlayNextPage', () => {
|
||||
expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull();
|
||||
});
|
||||
|
||||
it('never sends more excludes than the backend reads', async () => {
|
||||
const answers = Array.from({length: 40}, (_, round) => [game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)]);
|
||||
mockBackend(answers);
|
||||
it('never sends more excludes per row than the backend reads', async () => {
|
||||
const answers = Array.from({length: 40}, (_, round) =>
|
||||
row('NEXT_UP', game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)));
|
||||
mockRows([answers[0]], {NEXT_UP: answers.slice(1)});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -225,34 +313,19 @@ describe('PlayNextPage', () => {
|
||||
expect(largest).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('offers a retry when the first load fails', async () => {
|
||||
mockBackend([], {'GET /api/library/picker': () => { throw new TypeError('Failed to fetch'); }});
|
||||
it('rowKeepsItsSpinnerWhileAnotherRowFinishesFirst', async () => {
|
||||
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();
|
||||
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');
|
||||
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