Files
ToBePlayed-Frontend/src/pages/GameDetail.test.jsx
T
lobo ae904d946a
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s
feat(audit): undo button for merges, hide manual games
The audit log shows Undo on a merge row for 30 days, with a confirm
dialog and clear errors. The details page follows a merged key to the
kept game. Hide and Show now work for manual games too. de/en texts.
2026-10-02 13:39:02 +02:00

442 lines
21 KiB
React

// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {resetBrokenImageReports} from '../utils/brokenImages.js';
import {toast} from 'sonner';
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', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}});
const echo = (url, init) => json(200, JSON.parse(init.body));
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
'GET /api/users/me': json(200, me),
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
...routes,
});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
resetBrokenImageReports();
});
describe('GameDetail', () => {
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}});
renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull();
});
it('hides a Steam game and shows it again', async () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/220/hidden');
expect(JSON.parse(init.body)).toEqual({hidden: true});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
expect(screen.getByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeTruthy();
});
it('keeps the game shown when Core refuses', async () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('GAME_DETAIL.HIDE_ERROR'));
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
});
it('hides a game added by hand and shows it again', async () => {
answer({...game, appid: -1942, source: 'MANUAL', platform: 'PS5', hidden: false},
{'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942);
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
});
it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
const {container} = renderDetail();
await screen.findByText('A');
container.querySelectorAll('img[src^="https://x/"]').forEach((img) => fireEvent.error(img));
const reports = fetch.mock.calls.filter(([, init]) => init?.method === 'POST');
expect(reports).toHaveLength(1);
expect(reports[0][0]).toContain('/api/library/broken-images');
expect(JSON.parse(reports[0][1].body)).toEqual({appid: 220, kind: 'ACHIEVEMENT_ICONS'});
expect(container.querySelector('img[src^="https://x/"]')).toBeNull();
});
it('links the speedrun.com page when Core found one', async () => {
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
renderDetail();
const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/});
expect(link.getAttribute('href')).toBe('https://www.speedrun.com/hl2');
});
it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])(
'shows no speedrun link for %s', async (speedrunUrl) => {
answer({...game, speedrunUrl});
renderDetail();
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_SPEEDRUN')).toBeNull();
});
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
// Core changes only the status and answers the whole entry.
answer(game, {'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
renderDetail();
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
fireEvent.change(notes, {target: {value: 'my draft'}});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft');
});
it('uses the game as background and can remove it again', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'});
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/users/settings/appearance');
expect(JSON.parse(init.body).backgroundAppid).toBe(220);
fireEvent.click(remove);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
});
it('uses the game as banner and goes back to the automatic banner', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
const auto = await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'});
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
fireEvent.click(auto);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeTruthy();
});
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
});
});
describe('GameDetail of a game added by hand', () => {
const manualGame = {
...game, appid: -1025, name: 'Zelda', source: 'MANUAL', platform: 'Nintendo Switch', playtimeMinutes: 90,
playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg',
};
const noBody = {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }};
beforeEach(() => {
answer(manualGame, {'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
});
it('shows platform and IGDB picture, and offers no Steam actions', async () => {
const {container} = renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0);
expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeNull();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeNull();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('saves platform and hours', async () => {
renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' PS5 '}});
const saveButtons = screen.getAllByRole('button', {name: 'BACKLOG.SAVE'});
fireEvent.click(saveButtons.at(-1));
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/-1025/manual');
expect(JSON.parse(init.body)).toEqual({platform: 'PS5', playtimeMinutes: 150});
});
it('removes the game only after the confirm step and goes back to the library', async () => {
renderDetail(-1025);
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
expect(fetch.mock.calls.some(([, call]) => call?.method === 'DELETE')).toBe(false);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
expect(await screen.findByText('library page')).toBeTruthy();
const [url] = fetch.mock.calls.find(([, call]) => call?.method === 'DELETE');
expect(url).toContain('/api/library/games/-1025');
});
it('keeps Save off for an unchanged game with 75 minutes', async () => {
answer({...manualGame, playtimeMinutes: 75});
renderDetail(-1025);
await screen.findByLabelText('MANUAL_GAME.HOURS');
expect(screen.getAllByRole('button', {name: 'BACKLOG.SAVE'}).at(-1).disabled).toBe(true);
});
it('shows no manual card for a Steam game', async () => {
answer({...game, source: 'STEAM'});
renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
it('keeps a background set in another tab when it sets the profile banner', async () => {
let backgroundAppid = null;
answer({...game, source: 'STEAM'}, {
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}),
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
backgroundAppid = 620; // set in a second tab
fireEvent.keyDown(menu, {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true));
const sent = fetch.mock.calls.filter(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
expect(sent).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220, backgroundAppid: 620}]);
});
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
let bannerAppid = 220;
answer({...game, source: 'STEAM'}, {
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {bannerAppid}}}),
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
bannerAppid = 620; // another tab picked another game
fireEvent.keyDown(menu, {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true));
const [, init] = fetch.mock.calls.find(([u, i]) => i?.method === 'PUT' && u.endsWith('/profile-style'));
expect(JSON.parse(init.body).bannerAppid).toBe(220);
});
it('uses the art as profile banner and removes it on the second click', async () => {
let stored = {accent: 'red', corners: 'round'};
answer({...game, source: 'STEAM'}, {
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: stored}}),
'PUT /api/users/settings/profile-style': (url, init) => {
stored = JSON.parse(init.body);
return json(204);
},
});
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
const openMenu = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}), {key: 'Enter'});
renderDetail();
await openMenu();
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
await waitFor(() => expect(styleBodies()).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220,
backgroundAppid: null}]));
await openMenu();
const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'});
expect(banner.getAttribute('aria-checked')).toBe('true');
fireEvent.click(banner);
await waitFor(() => expect(styleBodies()).toHaveLength(2));
expect(styleBodies()[1].bannerAppid).toBeNull();
});
describe('corrections', () => {
const loggedInAs = (me) => answer(game, {}, me);
it('offers "correct data" to an active auditor', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'}));
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
it('offers "merge duplicate" to an active auditor', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'}));
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy();
});
it('showsTheOtherEditionsAndOffersUnlinkOnlyToAnAuditorOnALinkedGame', async () => {
const linked = {...game, linked: true, editions: [{appid: -7, name: 'Half-Life 2 PS5', platform: 'PS5', source: 'MANUAL', status: 'FINISHED'}]};
answer(linked, {'DELETE /api/library/games/220/link': json(204)}, {role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true));
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
});
it('offersNoUnlinkWithoutALink', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
await screen.findByRole('button', {name: 'LINKS.LINK_WITH'});
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
});
it('offers nothing to a user or to an admin viewing as user', async () => {
for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) {
loggedInAs(me);
renderDetail();
await screen.findByText('Half-Life 2');
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
// Let the answer of /me reach the page before looking for the button.
await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup();
}
});
});
});
describe('GameDetail edition requests', () => {
it('opensTheSameGameDialogForAUser', async () => {
answer(game, {}, {role: 'USER', activeRole: 'USER'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.SAME_AS'}));
expect(await screen.findByText('LINKS.DIALOG_HINT')).toBeTruthy();
});
});
describe('GameDetail confetti', () => {
// jsdom has no canvas drawing.
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
const opened = async (entry) => {
answer({...game, ...entry});
const {container} = renderDetail();
await screen.findByText('Half-Life 2');
return container.querySelector('canvas[data-size]');
};
it('plays a small one for a finished game', async () => {
expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small');
});
it('plays a big one when every achievement is unlocked too', async () => {
const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
achievementsTotal: 3, achievementsUnlocked: 3});
expect(canvas.dataset.size).toBe('big');
});
it('plays nothing for a game not finished', async () => {
expect(await opened({status: 'PLAYING'})).toBeNull();
});
});
describe('GameDetail session', () => {
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
toast.error.mockClear();
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games/220': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220'],
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/220'});
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
});
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
});
const {router} = renderDetail(-7);
await waitFor(() => expect(router.state.location.pathname).toBe('/library/220'));
expect(router.state.historyAction).toBe('REPLACE');
});
});