Public Access
feat(ui): make stat numbers clickable to open games
StatDrill context holds one dialog for a page. StatCount wraps a number: total and status counts link to the library filter, other numbers open StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars (genres, platforms, moods) and other stat displays. Dialog shows 40 games per page with bottom-sheet layout on phones, handles pagination and errors (403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
1 parent
ed5df8c40d
commit
777ba87fd6
31 files changed
+1026
-261
No files matched your search
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import StatGamesDialog from './StatGamesDialog.jsx';
|
||||
import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
||||
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
|
||||
|
||||
const GAMES = '/api/library/stats/games';
|
||||
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
|
||||
{appid: from + i, name: `Game ${from + i}`, status: 'BACKLOG', playtimeMinutes: 60, ...extra}));
|
||||
const page = (content, index, totalPages, totalElements) =>
|
||||
json(200, {content, page: index, size: 40, totalElements, totalPages});
|
||||
|
||||
const renderDialog = (request, onClose = () => {}) => render(
|
||||
<MemoryRouter><StatGamesDialog request={{title: 'Shooter', source: 'own', params: {key: 'GENRE', arg: 'Shooter'}, ...request}}
|
||||
onClose={onClose}/></MemoryRouter>);
|
||||
|
||||
describe('StatGamesDialog', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('loadsPageZeroAndAppendsTheNextOne', async () => {
|
||||
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
|
||||
? page(rows(41, 5), 1, 2, 45)
|
||||
: page(rows(1, 40), 0, 2, 45)});
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText('Game 40')).toBeTruthy();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(40);
|
||||
expect(fetch.mock.calls[0][0]).toContain('key=GENRE&arg=Shooter&page=0');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.LOAD_MORE'}));
|
||||
|
||||
expect(await screen.findByText('Game 45')).toBeTruthy();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(45);
|
||||
expect(screen.queryByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})).toBeNull();
|
||||
});
|
||||
|
||||
it('closesOnEscape', async () => {
|
||||
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
|
||||
const onClose = vi.fn();
|
||||
renderDialog({}, onClose);
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offersARetryAfterAnError', async () => {
|
||||
let fail = true;
|
||||
fetchRoutes({[`GET ${GAMES}`]: () => (fail ? json(500, {}) : page(rows(1, 1), 0, 1, 1))});
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText('STATS.DRILL.ERROR')).toBeTruthy();
|
||||
fail = false;
|
||||
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.RETRY'}));
|
||||
|
||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saysSoWhenTheOwnerDoesNotShareTheNumber', async () => {
|
||||
fetchRoutes({'GET /api/public/profiles/library/stats/games': json(403, {code: 'NOT_SHARED'})});
|
||||
renderDialog({source: 'profile', ticket: 't'});
|
||||
|
||||
expect(await screen.findByText('STATS.DRILL.NOT_SHARED')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'DASHBOARD.RETRY'})).toBeNull();
|
||||
});
|
||||
|
||||
it('asksForAReloadWhenTheTicketExpired', async () => {
|
||||
const mock = fetchRoutes({'GET /api/public/profiles/library/stats/games': json(401, {code: 'INVALID_TICKET'})});
|
||||
renderDialog({source: 'profile', ticket: 'old'});
|
||||
|
||||
expect(await screen.findByText('STATS.DRILL.EXPIRED')).toBeTruthy();
|
||||
expect(screen.getByRole('button', {name: 'STATS.DRILL.RELOAD'})).toBeTruthy();
|
||||
expect(mock.mock.calls[0][1].headers['X-Profile-Ticket']).toBe('old');
|
||||
});
|
||||
|
||||
it('showsTheStatusMoveOfATrendRow', async () => {
|
||||
fetchRoutes({[`GET ${GAMES}`]: page(
|
||||
[{appid: 5, name: 'Moved', fromStatus: 'BACKLOG', toStatus: 'PLAYING', at: '2026-09-20T10:00:00Z'}], 0, 1, 1)});
|
||||
renderDialog({params: {key: 'TREND_MOVES'}});
|
||||
|
||||
await screen.findByText('Moved');
|
||||
|
||||
expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsTheLibraryCountOfACommunityRow', async () => {
|
||||
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
|
||||
renderDialog({source: 'community', params: {key: 'OWNED'}});
|
||||
|
||||
await screen.findByText('Shared');
|
||||
|
||||
expect(screen.getByText('STATS.DRILL.IN_LIBRARIES:3 · 2.0h')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('focusReturnsToTheClickedNumber', async () => {
|
||||
fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)});
|
||||
render(<MemoryRouter><StatDrillProvider source="own">
|
||||
<StatCount params={{key: 'PLAYED'}} title="Played" label="Played: 1">1</StatCount>
|
||||
</StatDrillProvider></MemoryRouter>);
|
||||
const number = screen.getByRole('button', {name: 'Played: 1'});
|
||||
number.focus();
|
||||
fireEvent.click(number);
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||
await act(async () => {});
|
||||
|
||||
expect(document.activeElement).toBe(number);
|
||||
});
|
||||
|
||||
it('aLateAnswerOfAClosedListDoesNotFillTheOpenOne', async () => {
|
||||
let answerOld;
|
||||
fetchRoutes({[`GET ${GAMES}`]: (url) => (url.includes('arg=Old')
|
||||
? new Promise((resolve) => { answerOld = resolve; })
|
||||
: page([{appid: 2, name: 'New game', playtimeMinutes: 1}], 0, 1, 1))});
|
||||
const request = (arg) => ({title: arg, source: 'own', params: {key: 'GENRE', arg}});
|
||||
const view = render(<MemoryRouter><StatGamesDialog request={request('Old')} onClose={() => {}}/></MemoryRouter>);
|
||||
view.rerender(<MemoryRouter><StatGamesDialog request={request('New')} onClose={() => {}}/></MemoryRouter>);
|
||||
await screen.findByText('New game');
|
||||
|
||||
await act(async () => answerOld(page([{appid: 1, name: 'Old game', playtimeMinutes: 1}], 0, 1, 1)));
|
||||
|
||||
expect(screen.queryByText('Old game')).toBeNull();
|
||||
expect(screen.getByText('New game')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user