Public Access
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.
207 lines
7.4 KiB
React
207 lines
7.4 KiB
React
// @vitest-environment jsdom
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
|
import {toast} from 'sonner';
|
|
import useSetStatus from './useSetStatus.js';
|
|
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 pointsCheck = vi.fn(() => Promise.resolve(null));
|
|
vi.mock('./usePointsCheck.js', () => ({default: () => pointsCheck}));
|
|
|
|
const PUT = 'PUT /api/library/games/220/status';
|
|
|
|
beforeEach(() => {
|
|
localStorage.setItem('tbp_session', '1');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
});
|
|
|
|
const render = (options) => renderHook(() => useSetStatus(220, options));
|
|
|
|
describe('useSetStatus', () => {
|
|
it('savesTheStatusAndShowsTheSavedToast', async () => {
|
|
const fetchMock = fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING'})});
|
|
const {result} = render();
|
|
|
|
let saved;
|
|
await act(async () => {
|
|
saved = await result.current.setStatus('PLAYING');
|
|
});
|
|
|
|
expect(saved).toBe(true);
|
|
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING'});
|
|
});
|
|
|
|
it('passesTheSavedEntryToOnChanged', async () => {
|
|
fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING', appid: 220})});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged});
|
|
|
|
await act(() => result.current.setStatus('PLAYING'));
|
|
|
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
|
expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', appid: 220});
|
|
});
|
|
|
|
it('countsAnEmptyBodyAsSavedAndPassesNull', async () => {
|
|
fetchRoutes({[PUT]: () => new Response(null, {status: 200})});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged});
|
|
|
|
let saved;
|
|
await act(async () => {
|
|
saved = await result.current.setStatus('PLAYING');
|
|
});
|
|
|
|
expect(saved).toBe(true);
|
|
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
expect(onChanged).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('showsSaveFailedOnAServerError', async () => {
|
|
fetchRoutes({[PUT]: () => json(500, {})});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged});
|
|
|
|
await act(() => result.current.setStatus('PLAYING'));
|
|
|
|
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
|
|
expect(onChanged).not.toHaveBeenCalled();
|
|
expect(result.current.busy).toBe(false);
|
|
});
|
|
|
|
it('showsTheNetworkErrorWhenFetchRejects', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
fetchRoutes({[PUT]: () => {
|
|
throw new Error('offline');
|
|
}});
|
|
const {result} = render();
|
|
|
|
await act(() => result.current.setStatus('PLAYING'));
|
|
|
|
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
|
|
});
|
|
|
|
it('staysSilentOn401', async () => {
|
|
fetchRoutes({[PUT]: () => json(401, {}), 'POST /api/auth/refresh': () => json(401, {})});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged});
|
|
|
|
let saved;
|
|
await act(async () => {
|
|
saved = await result.current.setStatus('PLAYING');
|
|
});
|
|
|
|
expect(saved).toBe(false);
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
expect(onChanged).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sendsOneRequestWhenCalledTwice', async () => {
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const fetchMock = fetchRoutes({[PUT]: () => held});
|
|
const {result} = render();
|
|
|
|
await act(async () => {
|
|
const first = result.current.setStatus('PLAYING');
|
|
const second = await result.current.setStatus('PLAYING');
|
|
expect(second).toBe(false);
|
|
release(json(200, {}));
|
|
await first;
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('checksPointsOnlyWhenAsked', async () => {
|
|
fetchRoutes({[PUT]: () => json(200, {})});
|
|
const plain = render();
|
|
await act(() => plain.result.current.setStatus('PLAYING'));
|
|
expect(pointsCheck).not.toHaveBeenCalled();
|
|
|
|
const asking = render({checkPoints: true});
|
|
await act(() => asking.result.current.setStatus('PLAYING'));
|
|
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'PLAYING', tell: true});
|
|
});
|
|
|
|
it('isBusyWhileSaving', async () => {
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
fetchRoutes({[PUT]: () => held});
|
|
const {result} = render();
|
|
|
|
let pending;
|
|
await act(async () => {
|
|
pending = result.current.setStatus('PLAYING');
|
|
});
|
|
expect(result.current.busy).toBe(true);
|
|
await act(async () => {
|
|
release(json(200, {}));
|
|
await pending;
|
|
});
|
|
|
|
expect(result.current.busy).toBe(false);
|
|
});
|
|
|
|
it('showsUndoOnlyWithAPreviousStatusThatDiffers', async () => {
|
|
fetchRoutes({[PUT]: () => json(200, {})});
|
|
const {result} = render();
|
|
|
|
await act(() => result.current.setStatus('PLAYING'));
|
|
await act(() => result.current.setStatus('PLAYING', {previous: 'PLAYING'}));
|
|
expect(toast.success.mock.calls.every(([, options]) => options === undefined)).toBe(true);
|
|
|
|
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
|
|
const [message, options] = toast.success.mock.calls.at(-1);
|
|
expect(message).toBe('BACKLOG.SAVED');
|
|
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
|
|
expect(options.duration).toBe(8000);
|
|
});
|
|
|
|
it('undoSavesThePreviousStatusAndTellsOnChanged', async () => {
|
|
const fetchMock = fetchRoutes({[PUT]: (url, init) => json(200, JSON.parse(init.body))});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged, checkPoints: true});
|
|
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
|
|
onChanged.mockClear();
|
|
pointsCheck.mockClear();
|
|
|
|
await act(async () => toast.success.mock.calls[0][1].action.onClick());
|
|
|
|
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({status: 'BACKLOG'});
|
|
expect(onChanged).toHaveBeenCalledWith({status: 'BACKLOG'});
|
|
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'BACKLOG', tell: true});
|
|
// The undo itself has no Undo.
|
|
expect(toast.success.mock.calls.at(-1)).toEqual(['BACKLOG.SAVED']);
|
|
});
|
|
|
|
it('undoShowsSaveFailedOnAServerError', async () => {
|
|
let calls = 0;
|
|
fetchRoutes({[PUT]: () => json(++calls === 1 ? 200 : 500, {})});
|
|
const onChanged = vi.fn();
|
|
const {result} = render({onChanged});
|
|
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
|
|
onChanged.mockClear();
|
|
|
|
await act(async () => toast.success.mock.calls[0][1].action.onClick());
|
|
|
|
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
|
|
expect(onChanged).not.toHaveBeenCalled();
|
|
});
|
|
});
|