Public Access
Synthwave, fireflies, snow, sakura, nebula: CSS/SVG. Matrix and constellation: canvas. Existing engines gain depth, color, glow, and parallax (mouse and device tilt, opt-in on iOS)
232 lines
11 KiB
React
232 lines
11 KiB
React
// @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 <button onClick={syncPreferences}>load</button>;
|
|
}
|
|
|
|
const renderPage = async (routes = {}) => {
|
|
fetchRoutes({'GET /api/users/me': json(200, {}), 'GET /api/rewards/wallet': json(200, wallet()), ...routes});
|
|
renderWithProviders(<><Loaded/><PointsPage/></>, {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('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')).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('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(<><Loaded/><PointsPage/></>, {entries: ['/points']});
|
|
fireEvent.click(screen.getByText('load'));
|
|
await screen.findByText('POINTS.SHOP');
|
|
return mock;
|
|
}
|