// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; 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: {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, }); // Answers each picker call with the next list. function mockBackend(pickerAnswers, routes = {}) { const answers = [...pickerAnswers]; fetchRoutes({ 'GET /api/library/picker': () => json(200, {games: answers.shift() ?? []}), 'PUT /api/library/games/7/status': (url, init) => json(200, JSON.parse(init.body)), ...routes, }); } const pickerUrls = () => globalThis.fetch.mock.calls .map(([url]) => url) .filter((url) => url.includes('/api/library/picker')) .map((url) => new URL(url, 'http://x').searchParams); function renderPage() { render(); } describe('PlayNextPage', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); localStorage.clear(); }); it('shows three picks for any length by default', async () => { mockBackend([[game(1), game(2), game(3)]]); renderPage(); expect(await screen.findByText('Game 1')).toBeTruthy(); expect(screen.getByText('Game 3')).toBeTruthy(); const params = pickerUrls()[0]; expect(params.get('count')).toBe('3'); expect(params.has('maxMinutes')).toBe(false); expect(params.has('exclude')).toBe(false); }); it('asks for games up to the chosen length', async () => { mockBackend([[game(1)], [game(2)]]); renderPage(); await screen.findByText('Game 1'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'})); expect(await screen.findByText('Game 2')).toBeTruthy(); expect(pickerUrls()[1].get('maxMinutes')).toBe('300'); expect(pickerUrls()[1].has('exclude')).toBe(false); }); it('asks for games of the chosen mood and keeps the length', async () => { mockBackend([[game(1)], [game(2)], [game(3)]]); 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'); }); it('sends no mood for any mood and rerolls within the mood', async () => { mockBackend([[game(1)], [game(2)], [game(3)]]); renderPage(); await screen.findByText('Game 1'); expect(pickerUrls()[0].has('mood')).toBe(false); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'})); await screen.findByText('Game 2'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); await screen.findByText('Game 3'); expect(pickerUrls()[2].get('mood')).toBe('STORY'); expect(pickerUrls()[2].getAll('exclude')).toEqual(['2']); }); it('suggests another mood when none of the backlog fits', async () => { mockBackend([[game(1)], []]); 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(); }); it('asks for speedrun games and explains the background check', async () => { mockBackend([[game(1)], []]); 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 pick says to change the length', async () => { mockBackend([[game(1)], [game(2)], []]); 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.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)]}))}); 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(); }); it('rerolls without the games already shown', async () => { mockBackend([[game(1), game(2)], [game(3)]]); renderPage(); await screen.findByText('Game 1'); fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); 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(); }); it('starts playing with a status-only update', async () => { mockBackend([[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('lets the user try again when saving fails', async () => { mockBackend([[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 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); renderPage(); await screen.findByText('Game 1'); for (let round = 1; round < 40; round++) { fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'})); await screen.findByText(`Game ${round * 3 + 1}`); } const largest = Math.max(...pickerUrls().map((params) => params.getAll('exclude').length)); 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'); }}); renderPage(); await screen.findByText('INFO.ERROR.NETWORK_FAILURE'); expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull(); }); });