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:
lobo committed 2026-10-06 19:41:24 +02:00
1 parent 43c4862606
commit df3d6d2537
6 files changed
+338 -187

No files matched your search

+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+1 -1
View File
@@ -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
View File
@@ -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);
});
});