feat(audit): game catalog search and time to beat correction

New page /audit/games: search every game and open the correction dialog
without owning it. Time to beat as hours + minutes. Audit log game links
open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
lobo committed 2026-10-02 11:21:15 +02:00
1 parent 6627a8db66
commit 0c300c455d
14 files changed
+439 -19

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import GameCatalogPage from './GameCatalogPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
timeToBeat: null, corrections: []};
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (routes, url = '/audit/games') => {
fetchRoutes(routes);
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
};
const urls = () => fetch.mock.calls.map(([url]) => url);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameCatalogPage', () => {
it('listsHitsWithPlatformsAndKey', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
expect(await screen.findByText('Hades')).toBeTruthy();
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
});
it('searchesWithTheTypedName', async () => {
renderPage({'GET /api/library/catalog': catalog([hit])});
await screen.findByText('Hades');
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
});
it('opensTheCorrectionDialogForTheChosenGame', async () => {
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
});
it('opensTheGameOfTheAppidInTheUrl', async () => {
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
'/audit/games?appid=10');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
it('saysSoWhenTheGameIsGone', async () => {
const {toast} = await import('sonner');
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
'/audit/games?appid=10');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
});
it('showsNoAccessOn403', async () => {
renderPage({'GET /api/library/catalog': json(403, {})});
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
});
it('pagesThroughTheCatalog', async () => {
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
await screen.findByText('Hades');
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
});
});