Files
ToBePlayed-Frontend/src/pages/BacklogPage.test.jsx
T
lobo b213b067dc
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 24s
feat(library): add ordered backlog page and up-next dashboard tile
2026-10-06 09:06:51 +02:00

196 lines
8.1 KiB
React

// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import BacklogPage from './BacklogPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.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}`, priority: null, timeToBeatMinutes: null, ...extra});
const backlog = {games: [game(1), game(2), game(3), game(4, {coming: true, comingOn: '2099-01-01'})]};
// The providers load the user and the preferences; this page does not use them.
const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
const renderPage = () => renderWithProviders(<BacklogPage/>,
{path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library': <p>library</p>}});
const names = () => [...document.querySelectorAll('ol a[href^="/library/"]')].map((a) => a.textContent);
const puts = () => globalThis.fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
.map(([, init]) => JSON.parse(init.body).appids);
const rowButton = (rowName, label) => {
const row = screen.getByText(rowName).closest('li');
return [...row.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === label);
};
describe('BacklogPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows the games in the order of the server, a coming game too', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
expect(screen.getByText('LIBRARY.COMING', {exact: false})).toBeTruthy();
});
it('moves a game up and down with the arrows and sends the whole list', async () => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4]]));
expect(names()).toEqual(['Game 1', 'Game 3', 'Game 2', 'Game 4']);
fireEvent.click(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DOWN'));
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4], [3, 1, 2, 4]]));
});
it('does not offer to move the first game up or the last game down', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_UP').disabled).toBe(true);
expect(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_DOWN').disabled).toBe(true);
});
it.each([
['BACKLOG_ORDER.MOVE_TOP', 'Game 3', [3, 1, 2, 4]],
['BACKLOG_ORDER.MOVE_BOTTOM', 'Game 2', [1, 3, 4, 2]],
])('%s from the menu sends the new order', async (item, row, expected) => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.keyDown(rowButton(row, 'BACKLOG_ORDER.MORE'), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitem', {name: item}));
await waitFor(() => expect(puts()).toEqual([expected]));
});
it('moves a game with the drag handle to the row under the pointer', async () => {
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': json(204, {}),
});
renderPage();
await screen.findByText('Game 1');
document.querySelectorAll('ol > li').forEach((row, i) => {
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
});
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerMove(grip, {clientY: 240, pointerId: 1});
fireEvent.pointerUp(grip, {pointerId: 1});
await waitFor(() => expect(puts()).toEqual([[2, 3, 1, 4]]));
expect(names()).toEqual(['Game 2', 'Game 3', 'Game 1', 'Game 4']);
});
it('sends nothing when a drag ends on the row it started on, or is cancelled', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
document.querySelectorAll('ol > li').forEach((row, i) => {
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
});
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerUp(grip, {pointerId: 1});
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
fireEvent.pointerMove(grip, {clientY: 340, pointerId: 1});
fireEvent.pointerCancel(grip, {pointerId: 1});
expect(puts()).toEqual([]);
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
});
it('shows a toast and loads the list again when saving fails', async () => {
let loads = 0;
withAuth({
'GET /api/library/backlog': () => json(200, ++loads === 1 ? backlog : {games: [game(2), game(1)]}),
'PUT /api/library/backlog': json(500, {}),
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG_ORDER.SAVE_FAILED'));
await waitFor(() => expect(names()).toEqual(['Game 2', 'Game 1']));
});
it('sends only the newest list after a running save, so the server gets them in order', async () => {
let release;
const first = new Promise((resolve) => { release = resolve; });
let puts_ = 0;
withAuth({
'GET /api/library/backlog': json(200, backlog),
'PUT /api/library/backlog': async () => {
if (++puts_ === 1) await first;
return json(204, {});
},
});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
fireEvent.click(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_UP'));
expect(puts()).toEqual([[2, 1, 3, 4]]);
release();
await waitFor(() => expect(puts()).toEqual([[2, 1, 3, 4], [2, 3, 4, 1]]));
});
it('explains an empty backlog and links to the library', async () => {
withAuth({'GET /api/library/backlog': json(200, {games: []})});
renderPage();
expect(await screen.findByText('BACKLOG_ORDER.EMPTY')).toBeTruthy();
// The tab and the link of the empty card.
expect(screen.getAllByRole('link', {name: 'LIBRARY.TITLE'})).toHaveLength(2);
});
it('shows an error when the list cannot be loaded', async () => {
withAuth({'GET /api/library/backlog': json(500, {})});
renderPage();
expect(await screen.findByText('BACKLOG_ORDER.LOAD_FAILED')).toBeTruthy();
});
it('lets only the drag handle stop the page scroll', async () => {
withAuth({'GET /api/library/backlog': json(200, backlog)});
renderPage();
await screen.findByText('Game 1');
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG').className).toContain('touch-none');
const row = screen.getByText('Game 1').closest('li');
expect(row.className).not.toContain('touch-none');
expect(screen.getByText('Game 1').closest('ol').className).not.toContain('touch-none');
});
});