// @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(, {path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library':

library page

}}); const echo = (url, init) => json(200, JSON.parse(init.body)); // The user and the game at /library/; `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(409, {code: 'MANUAL_GAME'})}); 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('offers no hide for a game added by hand', async () => { answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false}); renderDetail(); await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).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('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(); cleanup(); } }); }); }); 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(, {path: '/library/:appid', entries: ['/library/220'], guarded: true, routes: {'/login':

login

}}); 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(); }); });