Public Access
feat(play-next): page that picks the next backlog game
This commit is contained in:
1 parent
8dcd03f219
commit
2efdf64675
10 files changed
+502
-7
No files matched your search
@@ -0,0 +1,168 @@
|
||||
// @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';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
// Answers each picker call with the next list and records every request.
|
||||
function mockBackend(pickerAnswers, detail = null) {
|
||||
const answers = [...pickerAnswers];
|
||||
globalThis.fetch = vi.fn(async (url, options = {}) => {
|
||||
if (url.includes('/api/library/picker')) return json({games: answers.shift() ?? []});
|
||||
if (options.method === 'PUT') return json(JSON.parse(options.body));
|
||||
return json(detail);
|
||||
});
|
||||
}
|
||||
|
||||
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(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('PlayNextPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
});
|
||||
|
||||
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('drops the old picks when a new budget fails to load', async () => {
|
||||
mockBackend([[game(1)]]);
|
||||
const pickerFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (url, options) =>
|
||||
(url.includes('maxMinutes=300') ? json({}, 500) : pickerFetch(url, options)));
|
||||
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)]]);
|
||||
const pickerFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (url, options) =>
|
||||
(options?.method === 'PUT' ? json({}, 500) : pickerFetch(url, options)));
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||
renderPage();
|
||||
await screen.findByText('INFO.ERROR.NETWORK_FAILURE');
|
||||
|
||||
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user