// @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'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})})); const game = (appid, extra = {}) => ({ appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null, timeToBeatMinutes: 600, ...extra, }); const row = (source, ...games) => ({source, games}); const ALL = () => [ 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('FRIENDS', game(11, {reason: {source: 'FRIENDS', friendName: 'Max', friendFinished: true, friendPlaytimeMinutes: null}}), game(12, {reason: {source: 'FRIENDS', friendName: 'Anna', friendFinished: false, friendPlaytimeMinutes: 2400}})), 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': (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)), 'GET /api/rewards/games/7': json(200, {balance: 5, missing: {}, paid: [ {kind: 'STARTED', appid: 7, points: 5, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}), 'GET /api/rewards/wallet': json(200, {balance: 5, catalog: [], owned: [], equipped: {}, history: []}), ...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(); } describe('PlayNextPage', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); localStorage.clear(); vi.clearAllMocks(); }); it('shows the rows in order with their headings, on the first load without category', async () => { mockRows(); renderPage(); await screen.findByText('Game 1'); 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.FRIENDS', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']); const params = pickerUrls()[0]; expect(params.has('count')).toBe(false); expect(params.has('category')).toBe(false); expect(params.has('maxMinutes')).toBe(false); expect(params.has('mood')).toBe(false); }); it('hides a row the backend leaves out', async () => { mockRows([row('NEXT_UP', game(1)), row('RANDOM', game(5))]); renderPage(); 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('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 9')).toBeTruthy(); expect(screen.queryByText('Game 5')).toBeNull(); expect(pickerUrls()).toHaveLength(2); expect(pickerUrls()[1].get('maxMinutes')).toBe('300'); expect(pickerUrls()[1].has('category')).toBe(false); }); 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.MOOD.COOP'})); 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('keeps the length when the mood changes', 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 1'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'})); await screen.findByText('Game 6'); expect(pickerUrls()[2].get('maxMinutes')).toBe('300'); }); 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('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('rerolls the friends row with category=FRIENDS and without a mood', async () => { mockRows(ALL(), {FRIENDS: [row('FRIENDS', game(13))]}); 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.FRIENDS')); await screen.findByText('Game 13'); expect(pickerUrls()[2].get('category')).toBe('FRIENDS'); 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'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SCARY'})); 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('explains the background check for speedrun without a length limit', async () => { mockRows(ALL(), {}); renderPage(); await screen.findByText('Game 1'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'})); expect(await screen.findByText('PLAY_NEXT.EMPTY_SPEEDRUN')).toBeTruthy(); expect(pickerUrls()[1].get('mood')).toBe('SPEEDRUN'); expect(screen.getByText('PLAY_NEXT.MOOD_HINT_SPEEDRUN')).toBeTruthy(); }); 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 1'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.SPEEDRUN'})); expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); }); it('shows a reason line for the series, similar, studio, friends and longest rows only', async () => { mockRows(); 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.FRIENDS_FINISHED')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.FRIENDS_PLAYED')).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'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'})); 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('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(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP')); 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 () => { mockRows([row('NEXT_UP', game(7, {priority: 'HIGH'}))]); renderPage(); await screen.findByText('Game 7'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})); expect(await screen.findByText('PLAY_NEXT.STARTED')).toBeTruthy(); const puts = globalThis.fetch.mock.calls.filter(([, options]) => options?.method === 'PUT'); expect(puts).toHaveLength(1); expect(puts[0][0]).toContain('/api/library/games/7/status'); expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'}); }); it('shows the points toast after Start', async () => { mockRows([row('NEXT_UP', game(7))]); renderPage(); await screen.findByText('Game 7'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})); await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:5')); expect(globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/7'))).toHaveLength(1); }); it('lets the user try again when saving fails', async () => { mockBackend(() => [row('NEXT_UP', game(7))], {'PUT /api/library/games/7/status': json(500, {})}); renderPage(); await screen.findByText('Game 7'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})); await waitFor(() => expect(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}).disabled).toBe(false)); expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull(); }); 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(rerollOf('PLAY_NEXT.CATEGORY.NEXT_UP')); await screen.findByText(`Game ${round * 3 + 1}`); } const largest = Math.max(...pickerUrls().map((params) => params.getAll('exclude').length)); expect(largest).toBeLessThanOrEqual(100); }); 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('Game 1'); 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); }); });