Public Access
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.
264 lines
13 KiB
React
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());
|
|
});
|
|
});
|