// @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(, {path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library':

library

}}); 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'); }); });