// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; import GameLibrary from './GameLibrary.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); const DEBOUNCE_WAIT_MS = 700; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); // MemoryRouter, not createMemoryRouter: it pushes like the BrowserRouter in main.jsx, also for an unchanged URL. function renderLibrary(initialEntry = '/library') { const lib = {navigations: 0, location: null, navigate: null}; function Spy() { const location = useLocation(); lib.navigate = useNavigate(); if (lib.location?.key !== location.key) { lib.navigations++; lib.location = location; } return null; } render( }/> , ); return { navigations: () => lib.navigations, params: () => new URLSearchParams(lib.location.search), back: () => lib.navigate(-1), }; } const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); beforeEach(() => { localStorage.setItem('tbp_jwt', 'token'); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); }); afterEach(() => { cleanup(); localStorage.clear(); }); describe('GameLibrary search', () => { it('writes the trimmed search to the URL after the delay and resets the page', async () => { const lib = renderLibrary('/library?page=3'); typeInto('half'); await waitFor(() => expect(lib.params().get('q')).toBe('half')); expect(lib.params().get('page')).toBe('0'); await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]q=half(&|$)/)); }); it('does not keep navigating and keeps a trailing space while the user types', async () => { const lib = renderLibrary(); typeInto('half'); await waitFor(() => expect(lib.params().get('q')).toBe('half')); const navigationsBefore = lib.navigations(); typeInto('half '); await wait(DEBOUNCE_WAIT_MS); expect(lib.navigations()).toBe(navigationsBefore); expect(screen.getByRole('searchbox').value).toBe('half '); }); it('keeps a status picked during the delay', async () => { const lib = renderLibrary(); typeInto('half'); fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'})); await waitFor(() => expect(lib.params().get('q')).toBe('half')); expect(lib.params().get('status')).toBe('DROPPED'); }); it('shows the URL search again after back navigation', async () => { const lib = renderLibrary(); typeInto('half'); await waitFor(() => expect(lib.params().get('q')).toBe('half')); act(() => lib.back()); await waitFor(() => expect(screen.getByRole('searchbox').value).toBe('')); }); }); describe('GameLibrary sort', () => { it.each(['rating,desc', 'priority,desc'])('sends the sort %s from the URL', async (sort) => { renderLibrary(`/library?sort=${encodeURIComponent(sort)}`); await waitFor(() => expect(fetch).toHaveBeenCalled()); expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort); }); });