Public Access
Detail page groups achievements by set: Steam, then PSN trophies. Tiles show the real count; a perfect game gets an amber trophy. Dashboard card shows for PSN-only users; CSP allows PSN icons.
529 lines
25 KiB
React
529 lines
25 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/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
|
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
|
|
...routes,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
localStorage.setItem('tbp_session', '1');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
localStorage.clear();
|
|
resetBrokenImageReports();
|
|
});
|
|
|
|
describe('GameDetail', () => {
|
|
it('showsHowManyOthersOwnTheGameAndOnlyNamesTheListedOnes', async () => {
|
|
answer(game, {'GET /api/stats/owners': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 1,
|
|
includesYou: true, page: 0, totalPages: 1})});
|
|
renderDetail();
|
|
|
|
expect(await screen.findByText('PEOPLE.OTHER_OWNERS:2')).toBeTruthy();
|
|
expect(screen.getByRole('link', {name: 'Lobo'}).getAttribute('href')).toBe('/u/Lobo');
|
|
expect(screen.getByText('PEOPLE.PRIVATE:1')).toBeTruthy();
|
|
expect(screen.queryByText('PEOPLE.YOU')).toBeNull();
|
|
expect(fetch.mock.calls.find(([url]) => url.includes('/api/stats/owners'))[0]).toContain('appid=220&page=0');
|
|
});
|
|
|
|
it('showsNoOwnersTitleWhenNobodyElseOwnsTheGame', async () => {
|
|
answer(game, {'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0,
|
|
includesYou: true, page: 0, totalPages: 0})});
|
|
renderDetail();
|
|
|
|
expect(await screen.findByText('PEOPLE.NONE')).toBeTruthy();
|
|
expect(screen.queryByText(/PEOPLE.OTHER_OWNERS/)).toBeNull();
|
|
});
|
|
|
|
it('shows the coming badge of a game that is not released yet', async () => {
|
|
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
|
|
renderDetail();
|
|
|
|
expect(await screen.findByText('LIBRARY.COMING')).toBeTruthy();
|
|
});
|
|
|
|
it('shows no coming badge for a released game', async () => {
|
|
answer({...game, source: 'STEAM', coming: false, comingOn: null});
|
|
renderDetail();
|
|
|
|
await screen.findByText('Half-Life 2');
|
|
|
|
expect(screen.queryByText('LIBRARY.COMING')).toBeNull();
|
|
expect(screen.queryByText('LIBRARY.COMING_SOON')).toBeNull();
|
|
});
|
|
|
|
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`, kind: 'STEAM', setId: null});
|
|
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
|
|
completionPercent: 0, achievements: [achievement('A'), achievement('B')],
|
|
achievementSets: [{id: 'steam', kind: 'STEAM', total: 2, unlocked: 0, perfect: false, visible: true, listed: true}]},
|
|
{'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('shows the trophies of a PlayStation game and not the no-achievements card', async () => {
|
|
answer({...game, source: 'PSN', hasAchievements: true, statsVisible: true,
|
|
achievements: [{apiName: '0', displayName: 'Legend', unlocked: true, kind: 'PSN', setId: 'NPWR1_00',
|
|
trophyType: 'PLATINUM', iconUrl: 'https://psnobj.prod.dl.playstation.net/0.png'}],
|
|
achievementSets: [{id: 'NPWR1_00', kind: 'PSN', platform: 'PS5', name: 'Ghost', total: 1, unlocked: 1,
|
|
perfect: true, visible: true, listed: true}]});
|
|
renderDetail();
|
|
|
|
expect(await screen.findByText('Legend')).toBeTruthy();
|
|
expect(screen.getByText('GAME_DETAIL.TROPHIES')).toBeTruthy();
|
|
expect(screen.queryByText('GAME_DETAIL.NO_ACHIEVEMENTS')).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('treats a game from a PlayStation import like a game added by hand', async () => {
|
|
answer({...manualGame, source: 'PSN', platform: 'PS5'},
|
|
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
|
|
renderDetail(-1025);
|
|
|
|
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
|
|
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
|
|
});
|
|
|
|
it('psnRowOffersNoHoursField', async () => {
|
|
answer({...manualGame, source: 'PSN', platform: 'PS5'});
|
|
renderDetail(-1025);
|
|
|
|
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
|
|
expect(screen.queryByLabelText('MANUAL_GAME.HOURS')).toBeNull();
|
|
});
|
|
|
|
it('shows when a PlayStation game was last played, and nothing for a game without that date', async () => {
|
|
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: '2026-10-05T18:30:00Z'},
|
|
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
|
|
renderDetail(-1025);
|
|
|
|
expect(await screen.findByText('GAME_DETAIL.LAST_PLAYED')).toBeTruthy();
|
|
cleanup();
|
|
|
|
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: null});
|
|
renderDetail(-1025);
|
|
|
|
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
|
|
expect(screen.queryByText('GAME_DETAIL.LAST_PLAYED')).toBeNull();
|
|
});
|
|
|
|
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/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
|
'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');
|
|
});
|
|
});
|