Public Access
Hero and library card show the set total. Fall back to the row's own minutes when the API sends no total.
441 lines
19 KiB
React
441 lines
19 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('hasNoHideDuplicatesToggle_AndTheRequestNeverCarriesTheParam', async () => {
|
|
renderLibrary('/library?hideDuplicates=true');
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
expect(screen.queryByRole('button', {name: 'LIBRARY.FILTER_HIDE_DUPLICATES'})).toBeNull();
|
|
expect(fetch.mock.calls.every(([url]) => !String(url).includes('hideDuplicates'))).toBe(true);
|
|
});
|
|
|
|
it('showsThePlatformsOfAnEntryInsteadOfTheSinglePlatformBadge', async () => {
|
|
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM', platform: null,
|
|
platforms: [{source: 'STEAM', platform: null}, {source: 'PSN', platform: 'PS5'}]}], true);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByLabelText('PS5')).toBeTruthy();
|
|
expect(screen.getByLabelText('Steam')).toBeTruthy();
|
|
expect(screen.queryByText('Steam')).toBeNull();
|
|
});
|
|
|
|
it('showsTheSetTotalPlaytimeOnTheCard_AndTheOwnMinutesWithoutATotal', async () => {
|
|
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 600, totalPlaytimeMinutes: 3600, status: 'BACKLOG'},
|
|
{appid: 620, name: 'Portal 2', playtimeMinutes: 1200, status: 'BACKLOG'}]);
|
|
renderLibrary();
|
|
|
|
expect(await screen.findByText('60h')).toBeTruthy();
|
|
expect(screen.getByText('20h')).toBeTruthy();
|
|
expect(screen.queryByText('10h')).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|