Public Access
Chips show only when the user has 2 or more sources. A selected source the user lacks stays visible, so it can be cleared.
424 lines
18 KiB
React
424 lines
18 KiB
React
// @vitest-environment jsdom
|
|
import {StrictMode} from 'react';
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
|
import GameLibrary from './GameLibrary.jsx';
|
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
|
import {toast} from 'sonner';
|
|
|
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
|
const syncOptions = vi.hoisted(() => ({current: null}));
|
|
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
|
|
syncOptions.current = opts;
|
|
return {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 answer = (games = page.content, canSync = true, hasSteamId = canSync, sources = []) => fetchRoutes({
|
|
'GET /api/users/me': json(200, {settings: {}}),
|
|
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
|
|
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync, sources}),
|
|
});
|
|
|
|
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
|
|
// A data router like main.jsx: a navigation to the same URL replaces the entry.
|
|
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;
|
|
}
|
|
const router = createMemoryRouter([{
|
|
path: '/library',
|
|
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/><Spy/></CurrentUserProvider></PreferencesProvider>,
|
|
}], {initialEntries: [initialEntry]});
|
|
render(<RouterProvider router={router}/>);
|
|
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_session', '1');
|
|
answer();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
localStorage.clear();
|
|
});
|
|
|
|
describe('GameLibrary search', () => {
|
|
it('shows the coming badge only for a game that is not released yet', async () => {
|
|
answer([
|
|
{...page.content[0], coming: true, comingOn: '2026-12-04'},
|
|
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', coming: false, comingOn: null},
|
|
]);
|
|
renderLibrary();
|
|
|
|
await screen.findByText('Portal 2');
|
|
|
|
expect(screen.getAllByText('LIBRARY.COMING')).toHaveLength(1);
|
|
expect(screen.getByText('Half-Life 2').closest('li').textContent).toContain('LIBRARY.COMING');
|
|
expect(screen.getByText('Portal 2').closest('li').textContent).not.toContain('LIBRARY.COMING');
|
|
});
|
|
|
|
it('shows the achievement progress of a game when it is known', async () => {
|
|
answer([
|
|
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
|
|
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
|
|
]);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByText('3/4')).toBeTruthy();
|
|
expect(screen.queryByText(/\/5$/)).toBeNull();
|
|
});
|
|
|
|
it('shows the real count of a game with a platinum trophy and marks it perfect', async () => {
|
|
answer([{...page.content[0], achievementsUnlocked: 30, achievementsTotal: 50, achievementsPerfect: true}]);
|
|
renderLibrary();
|
|
|
|
const badge = (await screen.findByText('30/50')).closest('[data-slot="badge"]');
|
|
expect(badge.querySelector('svg.text-amber-500')).not.toBeNull();
|
|
});
|
|
|
|
it('shows the newest search when an older answer comes last', async () => {
|
|
const later = [];
|
|
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
|
|
fetchRoutes({
|
|
'GET /api/users/me': json(200, {settings: {}}),
|
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true}),
|
|
'GET /api/library/games': (url, init) => {
|
|
const q = new URL(url, 'http://x').searchParams.get('q');
|
|
if (!q) return games('Half-Life 2');
|
|
// The first search answers only after the second one.
|
|
return new Promise((resolve, reject) => {
|
|
later.push({q, answer: () => resolve(games(q === 'ha' ? 'Old answer' : 'New answer'))});
|
|
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
|
});
|
|
},
|
|
});
|
|
renderLibrary();
|
|
await screen.findByText('Half-Life 2');
|
|
|
|
typeInto('ha');
|
|
await waitFor(() => expect(later).toHaveLength(1));
|
|
typeInto('half');
|
|
await waitFor(() => expect(later).toHaveLength(2));
|
|
await act(async () => later[1].answer());
|
|
await act(async () => later[0].answer());
|
|
|
|
expect(screen.getByText('New answer')).toBeTruthy();
|
|
expect(screen.queryByText('Old answer')).toBeNull();
|
|
});
|
|
|
|
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'));
|
|
|
|
await act(async () => 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);
|
|
});
|
|
});
|
|
|
|
describe('GameLibrary short games', () => {
|
|
it('filters by time to beat and keeps the chip state in the URL', async () => {
|
|
const lib = renderLibrary();
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}));
|
|
|
|
await waitFor(() => expect(lib.params().get('maxMinutes')).toBe('600'));
|
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]maxMinutes=600(&|$)/));
|
|
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true');
|
|
});
|
|
|
|
it('shows only the hidden games and clears the other filters', async () => {
|
|
const lib = renderLibrary('/library?status=BACKLOG&maxMinutes=600');
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
|
|
|
|
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
|
|
expect(lib.params().get('status')).toBeNull();
|
|
expect(lib.params().get('maxMinutes')).toBeNull();
|
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hidden=true(&|$)/));
|
|
expect(fetch.mock.calls.at(-1)[0]).not.toMatch(/status=|maxMinutes=/);
|
|
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_ALL'}).getAttribute('aria-pressed')).toBe('false');
|
|
});
|
|
|
|
it('showsTheEditionsBadgeOnlyFromTwo', async () => {
|
|
answer([{...page.content[0], editions: 2}, {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', editions: null}]);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByText('x2')).toBeTruthy();
|
|
expect(screen.getAllByText(/^x\d/)).toHaveLength(1);
|
|
});
|
|
|
|
it('hideDuplicatesFilter_AddsHideDuplicatesToTheUrlAndTheRequest', async () => {
|
|
const lib = renderLibrary();
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}));
|
|
|
|
await waitFor(() => expect(lib.params().get('hideDuplicates')).toBe('true'));
|
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]hideDuplicates=true(&|$)/));
|
|
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'}).getAttribute('aria-pressed')).toBe('true');
|
|
});
|
|
|
|
it('leaves the hidden games with a status click', async () => {
|
|
const lib = renderLibrary('/library?hidden=true');
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.PLAYING'}));
|
|
|
|
await waitFor(() => expect(lib.params().get('status')).toBe('PLAYING'));
|
|
expect(lib.params().get('hidden')).toBeNull();
|
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).not.toMatch(/hidden=/));
|
|
});
|
|
|
|
it('ignores a status in a hidden link', async () => {
|
|
renderLibrary('/library?hidden=true&status=BACKLOG');
|
|
const gamesUrl = () => fetch.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/api/library/games?'));
|
|
|
|
await waitFor(() => expect(gamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
|
|
expect(gamesUrl()).not.toMatch(/status=/);
|
|
});
|
|
|
|
it('says so when no game is hidden', async () => {
|
|
answer([]);
|
|
renderLibrary('/library?hidden=true');
|
|
|
|
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('GameLibrary source filter', () => {
|
|
const lastGamesUrl = () => String(fetch.mock.calls.map(([url]) => url).filter((url) => String(url).includes('/api/library/games?')).at(-1));
|
|
const chip = (source) => screen.getByRole('button', {name: `LIBRARY.SOURCE.${source}`});
|
|
|
|
it('sourceFilter_AddsSourceToTheUrlAndTheRequest', async () => {
|
|
answer(page.content, true, true, ['STEAM', 'PSN']);
|
|
const lib = renderLibrary('/library?page=2');
|
|
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
|
|
|
|
fireEvent.click(chip('STEAM'));
|
|
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM'));
|
|
fireEvent.click(chip('PSN'));
|
|
|
|
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM,PSN'));
|
|
expect(lib.params().get('page')).toBe('0');
|
|
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]source=STEAM%2CPSN(&|$)/));
|
|
expect(chip('STEAM').getAttribute('aria-pressed')).toBe('true');
|
|
fireEvent.click(chip('STEAM'));
|
|
await waitFor(() => expect(lib.params().get('source')).toBe('PSN'));
|
|
fireEvent.click(chip('PSN'));
|
|
await waitFor(() => expect(lib.params().get('source')).toBeNull());
|
|
});
|
|
|
|
it('sourceFilter_HiddenWithOneOwnSource', async () => {
|
|
answer(page.content, true, true, ['STEAM']);
|
|
renderLibrary();
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
await wait(50);
|
|
|
|
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
|
|
});
|
|
|
|
it('sourceFilter_ShowsASelectedSourceTheUserLacks', async () => {
|
|
answer(page.content, true, true, ['STEAM']);
|
|
const lib = renderLibrary('/library?source=PSN');
|
|
|
|
expect((await screen.findByRole('button', {name: 'LIBRARY.SOURCE.PSN'})).getAttribute('aria-pressed')).toBe('true');
|
|
fireEvent.click(chip('PSN'));
|
|
await waitFor(() => expect(lib.params().get('source')).toBeNull());
|
|
});
|
|
|
|
it('sourceFilter_IgnoresUnknownValuesInTheUrl', async () => {
|
|
answer(page.content, true, true, ['STEAM', 'PSN']);
|
|
renderLibrary('/library?source=XBOX');
|
|
|
|
await waitFor(() => expect(lastGamesUrl()).toContain('/api/library/games?'));
|
|
expect(lastGamesUrl()).not.toMatch(/source=/);
|
|
});
|
|
|
|
it('hiddenFilter_ClearsTheSource', async () => {
|
|
answer(page.content, true, true, ['STEAM', 'PSN']);
|
|
const lib = renderLibrary('/library?source=PSN');
|
|
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
|
|
|
|
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
|
|
expect(lib.params().get('source')).toBeNull();
|
|
await waitFor(() => expect(lastGamesUrl()).not.toMatch(/source=/));
|
|
});
|
|
|
|
it('hiddenLink_IgnoresTheSource', async () => {
|
|
renderLibrary('/library?hidden=true&source=PSN');
|
|
|
|
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
|
|
expect(lastGamesUrl()).not.toMatch(/source=/);
|
|
});
|
|
|
|
it('sourceFilter_ChipsFollowAFinishedSync', async () => {
|
|
answer(page.content, true, true, ['STEAM']);
|
|
renderLibrary();
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
await wait(50);
|
|
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
|
|
|
|
answer(page.content, true, true, ['STEAM', 'PSN']);
|
|
await act(async () => syncOptions.current.onComplete());
|
|
|
|
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
|
|
});
|
|
|
|
it('sourceFilter_ChipsShowUnderStrictMode', async () => {
|
|
answer(page.content, true, true, ['STEAM', 'PSN']);
|
|
const router = createMemoryRouter([{
|
|
path: '/library',
|
|
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/></CurrentUserProvider></PreferencesProvider>,
|
|
}], {initialEntries: ['/library']});
|
|
render(<StrictMode><RouterProvider router={router}/></StrictMode>);
|
|
|
|
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
|
|
});
|
|
|
|
it('sourceFilter_EmptyResultShowsNoMatch', async () => {
|
|
answer([], true, true, ['STEAM', 'PSN']);
|
|
renderLibrary('/library?source=PSN');
|
|
|
|
expect(await screen.findByText('LIBRARY.NO_MATCH')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('GameLibrary with games added by hand', () => {
|
|
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
|
|
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
|
|
|
|
it('shows the platform and the IGDB picture of a manual game', async () => {
|
|
answer([zelda], true);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByText('Nintendo Switch')).toBeTruthy();
|
|
expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
|
|
});
|
|
|
|
it('shows Steam as the platform of a Steam game, and links the import', async () => {
|
|
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
|
|
platform: null}], true);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByText('Steam')).toBeTruthy();
|
|
expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import');
|
|
});
|
|
|
|
it('shows the sync for a user with a PlayStation link and no Steam ID', async () => {
|
|
answer([zelda], true, false);
|
|
renderLibrary();
|
|
|
|
await screen.findByText('Zelda');
|
|
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
|
|
});
|
|
|
|
it('hides the sync for a user who has neither a Steam ID nor a PlayStation link but still offers adding a game', async () => {
|
|
answer([zelda], false);
|
|
renderLibrary();
|
|
|
|
await screen.findByText('Zelda');
|
|
await waitFor(() => expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull());
|
|
expect(screen.getByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'})).toBeTruthy();
|
|
});
|
|
|
|
it('opens the add panel', async () => {
|
|
answer([zelda], true);
|
|
renderLibrary();
|
|
|
|
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'}));
|
|
|
|
expect(screen.getByText('MANUAL_GAME.ADD_TITLE')).toBeTruthy();
|
|
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('GameLibrary session', () => {
|
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
|
fetchRoutes({
|
|
'GET /api/users/me': json(200, {settings: {}}),
|
|
'GET /api/library/games': json(401, {}),
|
|
'GET /api/library/games-count': json(401, {}),
|
|
'POST /api/auth/refresh': json(401, {}),
|
|
});
|
|
const {router} = renderWithProviders(<GameLibrary/>, {path: '/library', entries: ['/library?q=zelda'],
|
|
guarded: true, routes: {'/login': <p>login</p>}});
|
|
|
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=zelda'});
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
|
|
});
|
|
});
|