feat(library): set status and rating in one step (#56)

Library rows: the status badge is a menu that saves at once.
Game page: status and rating in the top card, saved on click.
Status changes and hiding a game show an Undo toast.
Other game actions moved into a "..." menu; split and unlink ask first.
New sorts: name, last played, time to beat; "last synced" is gone.
Page numbers and 44 px touch targets on phones.
This commit is contained in:
lobo committed 2026-10-21 11:09:44 +02:00
1 parent f9f0d94e2c
commit 45f5087227
32 files changed
+1091 -352

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import GameHeroCard from './GameHeroCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, name: 'Half-Life 2', status: 'BACKLOG', rating: null, hidden: false, playtimeMinutes: 10};
const routes = (extra = {}) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PUT /api/library/games/220/hidden': (url, init) => json(200, JSON.parse(init.body)),
...extra,
});
const renderCard = (props = {}) => renderWithProviders(
<GameHeroCard game={game} auditor={false} onCorrect={vi.fn()} onMerge={vi.fn()} onHiddenChanged={vi.fn()}
onProfileStyleSaved={vi.fn()} onSaved={vi.fn()} {...props}/>);
const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe('GameHeroCard', () => {
it('keepsTheMoreMenuForArtHideAndAuditorTools', async () => {
routes();
renderCard({auditor: true, profileStyle: {}});
expect(screen.getByRole('link', {name: /GAME_DETAIL.OPEN_IN_STEAM/})).toBeTruthy();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).toEqual(['GAME_DETAIL.SET_BACKGROUND', 'GAME_DETAIL.SET_BANNER', 'GAME_DETAIL.HIDE', 'CORRECTION.OPEN', 'MERGE.OPEN']);
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
});
it('offersNoAuditorToolsToAUser', async () => {
routes();
renderCard();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).not.toContain('CORRECTION.OPEN');
expect(names).not.toContain('MERGE.OPEN');
});
it('hidesWithAnUndoThatShowsTheGameAgain', async () => {
routes();
const onHiddenChanged = vi.fn();
renderCard({onHiddenChanged});
await openMore();
fireEvent.click(await screen.findByRole('menuitem', {name: 'GAME_DETAIL.HIDE'}));
await waitFor(() => expect(onHiddenChanged).toHaveBeenCalledWith(true));
const [message, options] = toast.success.mock.calls.at(-1);
expect(message).toBe('GAME_DETAIL.HIDDEN_TOAST');
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
await act(async () => options.action.onClick());
expect(onHiddenChanged).toHaveBeenLastCalledWith(false);
expect(calls('PUT').map(([, init]) => JSON.parse(init.body))).toEqual([{hidden: true}, {hidden: false}]);
// The undo is a plain toast.
expect(toast.success.mock.calls.at(-1)).toEqual(['GAME_DETAIL.SHOWN_TOAST']);
});
it('savesTheStatusFromTheMenuWithoutAskingThePointsItself', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({status: 'PLAYING'});
// GameDetail's GamePointsLine does the points check when the status changes; none here (no route would fail).
expect(toast.success.mock.calls.at(-1)[1].action.label).toBe('MANUAL_GAME.UNDO');
});
it('savesTheRatingAtOnce', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(calls('PATCH').map(([, init]) => JSON.parse(init.body))).toEqual([{rating: 4}]);
});
});