// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import GameDetail from './GameDetail.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.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) => render( }/> library page

}/>
, ); beforeEach(() => { localStorage.setItem('tbp_session', '1'); globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' ? {ok: true, status: 200, json: async () => JSON.parse(init.body)} : {ok: true, status: 200, json: async () => game}); }); afterEach(() => { cleanup(); localStorage.clear(); resetBrokenImageReports(); }); describe('GameDetail', () => { it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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 () => { globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' ? {ok: true, status: 200, json: async () => JSON.parse(init.body)} : {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})}); 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 () => { globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' ? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})} : {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})}); 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 () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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`}); globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST' ? {ok: true, status: 204} : {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0, achievements: [achievement('A'), achievement('B')]})}); 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 () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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) => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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 () => { 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 () => { 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 () => { 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})); 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', }; beforeEach(() => { globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE') ? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }} : {ok: true, status: 200, json: async () => manualGame}); }); 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 () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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 () => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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 section hidden after the page loaded when it sets the profile art', async () => { let sections = ['PLAYING']; globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, json: async () => (url.includes('/api/users/me') ? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', sections}}} : {...game, source: 'STEAM'})})); renderDetail(); const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}); sections = []; // the owner hides PLAYING in Settings, e.g. 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.filter((body) => body.sections?.includes('PLAYING'))).toEqual([]); }); it('keeps a banner picked in another tab when it sets this game as banner', async () => { let bannerAppid = 220; globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, json: async () => (url.includes('/api/users/me') ? {role: 'USER', settings: {profileStyle: {bannerAppid, sections: ['PLAYING']}}} : {...game, source: 'STEAM'})})); 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', sections: ['PLAYING']}; globalThis.fetch = vi.fn(async (url, init) => { if (init?.method === 'PUT') stored = JSON.parse(init.body); return {ok: true, status: init?.method === 'PUT' ? 204 : 200, json: async () => (url.includes('/api/users/me') ? {role: 'USER', settings: {profileStyle: stored}} : {...game, source: 'STEAM'})}; }); 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, sections: ['PLAYING']}])); 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) => { globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : game)})); }; 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) => { globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...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(); }); });