// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; import {toast} from 'sonner'; import PointsPage from './PointsPage.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); const wallet = (extra = {}) => ({ balance: 160, catalog: [ {code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150}, {code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200}, {code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100}, {code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300}, {code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450}, {code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300}, {code: 'BG_SNOW', slot: 'PAGE_BACKGROUND', price: 400}, {code: 'BN_MATRIX', slot: 'BANNER_BACKGROUND', price: 300}, {code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10}, {code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4}, {code: 'SKIN_PASTEL', slot: 'SKIN', price: 600}, ], progress: {GAMES_FINISHED: 7, FINISH_WEEKS: 9}, owned: ['BADGE_PILE_SLAYER'], equipped: {}, history: [ {kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'}, {kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'}, {kind: 'UNLOCK', ref: 'BADGE_PERFECT_5', gameName: null, points: 0, eventAt: '2026-09-29T10:00:00Z'}, ], ...extra, }); // Loads the wallet like App does after the login, then shows the page. function Loaded() { const {syncPreferences} = usePreferences(); return ; } const renderPage = async (routes = {}) => { fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': json(200, wallet()), ...routes}); renderWithProviders(<>, {entries: ['/points']}); fireEvent.click(screen.getByText('load')); await screen.findByText('POINTS.SHOP'); }; beforeEach(() => { localStorage.setItem('tbp_session', '1'); // jsdom has no canvas: the matrix preview renders, draws nothing. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); }); afterEach(() => { cleanup(); localStorage.clear(); vi.clearAllMocks(); }); describe('PointsPage', () => { it('lists all thirteen rules', async () => { await renderPage(); const rules = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH', 'STREAK', 'STARTER', 'UNLOCK']; const shown = screen.getAllByRole('listitem').map((item) => item.textContent); expect(shown.filter((text) => text.startsWith('POINTS.RULES.'))).toEqual(rules.map((rule) => `POINTS.RULES.${rule}`)); }); it('shows the effect slots with a preview of each look', async () => { await renderPage(); const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]'); const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild; expect(cover.classList.contains('fx-cover-gold-shine')).toBe(true); expect(card('PAGE_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull(); expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull(); }); it('previews the new CSS and canvas looks without parallax listeners', async () => { const add = vi.spyOn(window, 'addEventListener'); await renderPage(); const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]'); expect(card('PAGE_BACKGROUND').querySelector('[data-effect="snow"]')).not.toBeNull(); expect(card('BANNER_BACKGROUND').querySelector('[data-effect="matrix"] canvas')).not.toBeNull(); expect(add.mock.calls.filter(([type]) => type === 'pointermove')).toHaveLength(0); add.mockRestore(); }); it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => { await renderPage(); const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li'); expect(within(item).getByText('POINTS.PROGRESS.GAMES_FINISHED:7')).toBeTruthy(); const bar = within(item).getByRole('progressbar'); expect(bar.getAttribute('aria-valuenow')).toBe('7'); expect(bar.getAttribute('aria-valuemax')).toBe('10'); expect(within(item).queryByRole('button', {name: 'POINTS.BUY'})).toBeNull(); }); it('opens the games behind a locked bar, and the history of a month', async () => { const page = (appid) => json(200, {content: [{appid, name: `G${appid}`, status: 'FINISHED', at: '2026-09-01T10:00:00Z'}], page: 0, size: 40, totalElements: 1, totalPages: 1, untracked: 3}); await renderPage({ 'GET /api/rewards/progress/GAMES_FINISHED/games': page(1), 'GET /api/rewards/history/MONTH/2026-09/games': page(2), }); const bar = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li'); fireEvent.click(within(bar).getByRole('button')); expect(await screen.findByText('G1')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.BEFORE:3')).toBeTruthy(); fireEvent.keyDown(document.activeElement, {key: 'Escape'}); await waitFor(() => expect(screen.queryByText('G1')).toBeNull()); fireEvent.click(screen.getByRole('button', {name: '2026-09'})); expect(await screen.findByText('G2')).toBeTruthy(); }); it('shows no button on a playtime-month bar', async () => { await renderPage({'GET /api/rewards/wallet': json(200, wallet({ catalog: [{code: 'BADGE_MONTHS', slot: 'BADGE', price: 0, track: 'PLAY_MORE_MONTHS', target: 3}], }))}); const item = screen.getByText('POINTS.ITEMS.BADGE_MONTHS').closest('li'); expect(within(item).queryByRole('button')).toBeNull(); }); it('shows no button on a locked bar without progress', async () => { await renderPage({'GET /api/rewards/wallet': json(200, wallet({progress: {GAMES_FINISHED: 0}}))}); const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li'); expect(within(item).queryByRole('button')).toBeNull(); }); it('caps the progress at the target', async () => { await renderPage(); const item = screen.getByText('POINTS.ITEMS.BADGE_STREAK_4').closest('li'); expect(within(item).getByText('POINTS.PROGRESS.FINISH_WEEKS:4')).toBeTruthy(); expect(within(item).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('4'); }); it('lets the user equip an unlock item once it is owned', async () => { await renderPage({'GET /api/rewards/wallet': json(200, wallet({owned: ['BADGE_FINISH_10']}))}); const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li'); expect(within(item).getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy(); expect(within(item).queryByRole('progressbar')).toBeNull(); }); it('previews a skin with its data-skin', async () => { await renderPage(); const card = screen.getByText('POINTS.SLOTS.SKIN').closest('[data-slot="card"]'); expect(card.querySelector('[data-skin="pastel"]')).not.toBeNull(); }); it('shows an UNLOCK row with the item name and no number', async () => { await renderPage(); const row = screen.getByText(/POINTS.KINDS.UNLOCK/).closest('li'); expect(row.textContent).toContain('POINTS.ITEMS.BADGE_PERFECT_5'); expect(row.textContent).not.toMatch(/\+?0$/); }); it('holds the frame of previews that are off screen', async () => { const callbacks = []; vi.stubGlobal('IntersectionObserver', class { constructor(callback) { callbacks.push(callback); } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); }); try { await renderPage(); const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]'); const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild; const banner = card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]').parentElement; expect(cover.classList.contains('fx-paused')).toBe(false); act(() => callbacks[0]([{target: cover, isIntersecting: false}, {target: banner, isIntersecting: false}])); expect(cover.classList.contains('fx-paused')).toBe(true); expect(banner.classList.contains('fx-paused')).toBe(true); act(() => callbacks[0]([{target: cover, isIntersecting: true}])); expect(cover.classList.contains('fx-paused')).toBe(false); } finally { vi.unstubAllGlobals(); } }); it('groups the catalog by slot and disables Buy below the price', async () => { await renderPage(); const border = screen.getByText('POINTS.SLOTS.AVATAR_BORDER').closest('[data-slot="card"]'); const gold = within(border).getByText('POINTS.ITEMS.BORDER_GOLD').closest('li'); const neon = within(border).getByText('POINTS.ITEMS.BORDER_NEON').closest('li'); expect(within(gold).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(false); expect(within(neon).getByRole('button', {name: 'POINTS.BUY'}).disabled).toBe(true); expect(within(screen.getByText('POINTS.SLOTS.BADGE').closest('[data-slot="card"]')) .getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy(); }); it('shows the game name as a link and the ref for month rows', async () => { await renderPage(); expect(screen.getByRole('link', {name: 'Portal 2'}).getAttribute('href')).toBe('/library/620'); expect(screen.getByText(/2026-09/)).toBeTruthy(); expect(screen.getByText('+50')).toBeTruthy(); }); it('links early and top-up rows to the game', async () => { await renderPage({'GET /api/rewards/wallet': json(200, wallet({history: [ {kind: 'FINISHED_EARLY', ref: '10', gameName: 'Early Game', points: 10, eventAt: '2026-09-30T10:00:00Z'}, {kind: 'FINISHED_TOPUP', ref: '10', gameName: 'Early Game', points: 20, eventAt: '2026-09-30T10:00:00Z'}, ]}))}); const links = screen.getAllByRole('link', {name: 'Early Game'}); expect(links).toHaveLength(2); expect(links[0].getAttribute('href')).toBe('/library/10'); }); it('buys, then loads the wallet again', async () => { let balance = 160; const mock = await renderPageWith(() => json(200, wallet({balance})), { 'POST /api/rewards/purchases': () => { balance = 10; return json(200, wallet({balance})); }, }); fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'})); await waitFor(() => expect(screen.getByText('10')).toBeTruthy()); const post = mock.mock.calls.find(([, init]) => init?.method === 'POST'); expect(JSON.parse(post[1].body)).toEqual({item: 'BORDER_GOLD'}); }); it('shows the not-enough message on a 409', async () => { await renderPage({'POST /api/rewards/purchases': json(409, {code: 'NOT_ENOUGH_POINTS'})}); fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'})); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH')); }); it('an ALREADY_OWNED 409 reloads the wallet without the not-enough message', async () => { const mock = await renderPageWith(json(200, wallet()), {'POST /api/rewards/purchases': json(409, {code: 'ALREADY_OWNED'})}); fireEvent.click(within(screen.getByText('POINTS.ITEMS.BORDER_GOLD').closest('li')).getByRole('button', {name: 'POINTS.BUY'})); await waitFor(() => expect(mock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true)); await new Promise((resolve) => setTimeout(resolve, 20)); expect(toast.error).not.toHaveBeenCalledWith('POINTS.ERROR_NOT_ENOUGH'); }); it('sends the equip and unequip PUT', async () => { const puts = []; await renderPage({'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); }}); fireEvent.click(screen.getByRole('button', {name: 'POINTS.EQUIP'})); await waitFor(() => expect(puts).toEqual([{item: 'BADGE_PILE_SLAYER'}])); }); it('sends null to take a worn look off', async () => { const puts = []; await renderPage({ 'GET /api/rewards/wallet': json(200, wallet({equipped: {BADGE: 'BADGE_PILE_SLAYER'}})), 'PUT /api/rewards/equipped/BADGE': (url, init) => { puts.push(JSON.parse(init.body)); return json(200, wallet()); }, }); fireEvent.click(screen.getByRole('button', {name: 'POINTS.UNEQUIP'})); await waitFor(() => expect(puts).toEqual([{item: null}])); }); }); // A wallet answer that changes between calls. async function renderPageWith(walletAnswer, routes) { const mock = fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': walletAnswer, ...routes}); renderWithProviders(<>, {entries: ['/points']}); fireEvent.click(screen.getByText('load')); await screen.findByText('POINTS.SHOP'); return mock; }