Files
ToBePlayed-Frontend/src/pages/PublicProfilePage.test.jsx
T
lobo b6f3d123fb
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 25s
feat(points): unlock items, progress bars and skins
Locked items show a progress bar until the goal is reached. Four
CSS-only skins (Pastel, Retro CRT, Cyberpunk, Steam) in skins.css,
light and dark. A skin overrides accent and corners; badges, borders,
frames, effects and game art stay on top. New Platinum shine and
Equalizer effects.
2026-10-02 15:40:46 +02:00

264 lines
13 KiB
React

// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import PublicProfilePage from './PublicProfilePage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const library = {
totalGames: 233, totalPlaytimeMinutes: 60000,
byStatus: {BACKLOG: 142, PLAYING: 6, FINISHED: 51, COMPLETED: 9, DROPPED: 25},
playing: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 300, status: 'PLAYING', rating: null}],
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
};
// `me` only for a test with a session: without one the page asks nothing about the viewer.
function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) {
fetchRoutes({
[`GET /api/public/profiles/${encodeURIComponent(name)}`]:
json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}),
'GET /api/public/profiles/library': json(libraryStatus, answer),
'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}),
...(me && {
'GET /api/users/me': json(200, me),
'PUT /api/users/settings/profile-tiles': json(204, {}),
}),
});
}
const renderPage = (name = 'Lobo') => renderWithProviders(<PublicProfilePage/>, {path: '/u/:name', entries: [`/u/${name}`]});
describe('PublicProfilePage', () => {
afterEach(cleanup);
it('wears the premium accent and the badge of the owner', async () => {
mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_GOLD', BADGE: 'BADGE_PILE_SLAYER', BANNER_FRAME: 'FRAME_GOLD'}});
renderPage();
expect(await screen.findByText('POINTS.ITEMS.BADGE_PILE_SLAYER')).toBeTruthy();
const wrapper = screen.getByText('Lobo').closest('[data-accent]');
expect(wrapper.dataset.accent).toBe('gold');
expect(wrapper.querySelector('[data-slot="card"]').className).toContain('ring-amber-400');
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
});
it('wears the effects of the owner: background, banner and the cover of a COMPLETED game', async () => {
mockBackends({style: {accent: 'red'}, equipped: {
PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES', COMPLETED_EFFECT: 'FX_NEON_PULSE'}});
const {container} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
await waitFor(() => expect(container.querySelector('[data-effect="starfield"]')).not.toBeNull());
expect(screen.getByText('Lobo').closest('[data-slot="card"]').querySelector('[data-effect="waves"]')).not.toBeNull();
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
});
it('wears the skin of the owner on the wrapper with its own base layer, or resets to the plain theme', async () => {
mockBackends({style: {accent: 'red'}, equipped: {SKIN: 'SKIN_CYBERPUNK'}});
const {unmount} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
const wrapper = screen.getByText('Lobo').closest('[data-skin]');
expect(wrapper.dataset.skin).toBe('cyberpunk');
expect(wrapper.querySelector('.skin-bg')).not.toBeNull();
unmount();
cleanup();
mockBackends({style: {accent: 'red'}, equipped: {}});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-skin]').dataset.skin).toBe('none');
});
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
});
it('ignores a code the app does not know', async () => {
mockBackends({style: {accent: 'red'}, equipped: {ACCENT: 'ACCENT_X', BADGE: 'BADGE_X'}});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
});
it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends({name: 'Lo bo'});
renderPage('Lo bo');
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/public\/profiles\/Lo%20bo$/);
expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket');
expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('233')).toBeTruthy();
expect(screen.queryByText('PROFILE.PERFECT_GAMES')).toBeNull();
});
it('shows perfect games and the average completion when there are achievements', async () => {
library.perfectGames = 12;
library.averageCompletion = 64.5;
try {
mockBackends();
renderPage();
expect(await screen.findByText('PROFILE.PERFECT_GAMES')).toBeTruthy();
expect(screen.getByText('12')).toBeTruthy();
expect(screen.getByText('64,5 %')).toBeTruthy();
} finally {
delete library.perfectGames;
delete library.averageCompletion;
}
});
it('shows only the stat groups the user shares, without links', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
renderPage();
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
expect(screen.queryByText('STATS.PILE.TITLE')).toBeNull();
expect(screen.queryByText('STATS.TOP_GAMES.TITLE')).toBeNull();
delete library.stats;
});
it('applies the owner\'s accent and corners to the page only, with banner and background', async () => {
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, tiles: [{id: 'PLAYING', size: 'M'}]}});
const {container} = renderPage();
await screen.findByText('Half-Life 2');
const page = container.querySelector('[data-accent="red"]');
expect(page.dataset.corners).toBe('round');
expect(document.documentElement.dataset.accent).not.toBe('red');
expect([...container.querySelectorAll('img')].map((img) => img.getAttribute('src')))
.toEqual(expect.arrayContaining([expect.stringContaining('/620/'), expect.stringContaining('/220/')]));
});
it('shows the sections in the owner\'s order and leaves out hidden ones', async () => {
mockBackends({
style: {tiles: [{id: 'FINISHED', size: 'M'}, {id: 'PLAYING', size: 'S'}, {id: 'SUMMARY', size: 'M'}]},
answer: {...library, byStatus: null, finishedGames: 60},
});
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['FINISHED', 'PLAYING', 'SUMMARY']);
expect(screen.getByText('60')).toBeTruthy();
expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull();
});
it('leaves out the status bar of a profile without games', async () => {
mockBackends({answer: {...library, totalGames: 0, byStatus: {BACKLOG: 0, PLAYING: 0}, playing: [], finished: []}});
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']);
});
it('shows every section for an Auth without a style', async () => {
mockBackends();
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
expect(container.querySelector('[data-accent="violet"]')).toBeTruthy();
});
it('shows the small playing tile with half the games and at half width', async () => {
const games = Array.from({length: 8}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 60, status: 'PLAYING'}));
mockBackends({style: {tiles: [{id: 'PLAYING', size: 'S'}]}, answer: {...library, playing: games}});
const {container} = renderPage();
await screen.findByText('Game 0');
expect(screen.queryByText('Game 4')).toBeNull();
expect(container.querySelector('[data-tile="PLAYING"]').className).toContain('md:col-span-6');
});
const libraryLoads = () => fetch.mock.calls.filter(([url]) => url.includes('/api/public/profiles/library')).length;
const putBody = () => JSON.parse(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[1].body);
it('offers "Customize" to the owner and hides a tile without a new load', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'lobo', role: 'USER'}});
const {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
// The test texts carry no tile name; the first tile is SUMMARY.
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toMatch(/\/api\/users\/settings\/profile-tiles$/);
expect(putBody().tiles.map((tile) => tile.id)).toEqual(['STATUS', 'PLAYING', 'FINISHED']);
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).not.toContain('SUMMARY');
expect(libraryLoads()).toBe(1);
} finally {
localStorage.clear();
}
});
it('loads again after adding a tile, because Core sent no data for it', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
fireEvent.click(screen.getByRole('button', {name: 'STATS.PILE.TITLE'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(libraryLoads()).toBe(2));
expect(putBody().tiles.at(-1)).toEqual({id: 'STATS_PILE', size: 'M'});
} finally {
localStorage.clear();
}
});
it('shows no "Customize" to a visitor', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
renderPage();
await screen.findByText('Portal 2');
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
expect(screen.queryByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeNull();
} finally {
localStorage.clear();
}
});
it('keeps three numbers in a row on a phone', async () => {
mockBackends();
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
const summary = container.querySelector('[data-tile="SUMMARY"] [data-slot="card"]');
expect(summary.className.split(' ')).toContain('grid-cols-3');
});
it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404});
renderPage();
expect(await screen.findByText('PROFILE.NOT_FOUND')).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('shows an error when Core fails', async () => {
mockBackends({libraryStatus: 500});
renderPage();
await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy());
});
});