Public Access
The dashboard no longer waits for /me before it calls the library. History loads when games-count says canSync or fails. Refs #40.
314 lines
15 KiB
React
314 lines
15 KiB
React
// @vitest-environment jsdom
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
|
|
import Dashboard from './Dashboard.jsx';
|
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
|
|
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
|
const watchSync = vi.hoisted(() => vi.fn());
|
|
vi.mock('../hooks/useLibrarySync.js', () => ({
|
|
default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false, watch: watchSync}),
|
|
}));
|
|
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
|
|
|
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
|
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
|
|
const empty = {content: [], total: 0, byStatus: {}};
|
|
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
|
|
const quietRoutes = {
|
|
'GET /api/library/summary': json(200, empty),
|
|
'GET /api/hints': json(200, {results: []}),
|
|
'GET /api/news': json(200, {results: []}),
|
|
'GET /api/library/sync-changes': json(204, {}),
|
|
};
|
|
|
|
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
|
|
const dashboardRoutes = (settings) => ({
|
|
...quietRoutes,
|
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}),
|
|
'GET /api/library/games': (url) => json(200, mostPlayed(url) ? {content: games, total: 12} : empty),
|
|
'GET /api/library/stats': json(200, {trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}),
|
|
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: avatar}),
|
|
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
|
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
|
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true}),
|
|
'GET /api/library/backlog': json(200, {games: [{appid: 7, name: 'Hades'}]}),
|
|
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
|
|
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
|
|
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
|
|
});
|
|
|
|
const renderDashboard = () => renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
|
|
|
|
beforeEach(() => {
|
|
localStorage.setItem('tbp_session', '1');
|
|
});
|
|
|
|
afterEach(() => {
|
|
watchSync.mockClear();
|
|
cleanup();
|
|
localStorage.clear();
|
|
});
|
|
|
|
describe('Dashboard', () => {
|
|
it('opens the login page when the core API answers 401', async () => {
|
|
fetchRoutes({
|
|
...Object.fromEntries(Object.keys(dashboardRoutes()).map((key) => [key, json(401, {})])),
|
|
'GET /api/users/me': json(200, {email: 'a@b.de', steamId: '76561198000000000'}),
|
|
'POST /api/auth/refresh': json(401, {}),
|
|
});
|
|
const {router} = renderWithProviders(<Dashboard/>,
|
|
{path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login': <p>login</p>}});
|
|
|
|
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
|
|
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
|
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
|
|
});
|
|
|
|
const mockDashboard = (settings) => {
|
|
fetchRoutes(dashboardRoutes(settings));
|
|
return renderDashboard().container;
|
|
};
|
|
|
|
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
|
const container = mockDashboard();
|
|
|
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
|
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
|
|
});
|
|
|
|
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
|
|
const asked = (path) => fetch.mock.calls.some(([url]) => url.includes(path));
|
|
|
|
it('asks for the library before /me answers', async () => {
|
|
let answerMe;
|
|
const meAnswer = new Promise((resolve) => { answerMe = resolve; });
|
|
const routes = dashboardRoutes();
|
|
const me = routes['GET /api/users/me'];
|
|
fetchRoutes({...routes, 'GET /api/users/me': () => meAnswer});
|
|
const container = renderDashboard().container;
|
|
|
|
await waitFor(() => expect(asked('/api/library/games')).toBe(true));
|
|
expect(asked('/api/library/summary')).toBe(true);
|
|
|
|
answerMe(me);
|
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
|
});
|
|
|
|
it('shows today\'s tiles without a saved layout', async () => {
|
|
const container = mockDashboard();
|
|
|
|
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
|
|
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'UP_NEXT', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
|
expect(screen.getByText('Game 4')).toBeTruthy();
|
|
expect(screen.queryByText('Game 5')).toBeNull();
|
|
});
|
|
|
|
it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => {
|
|
const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]});
|
|
|
|
await screen.findByText('STATS.HABITS.TITLE');
|
|
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
|
|
expect(screen.getByText('Game 2')).toBeTruthy();
|
|
expect(screen.queryByText('Game 3')).toBeNull();
|
|
// TREND is always asked: the "pile grew" hint needs it.
|
|
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
|
|
});
|
|
|
|
it('showsAStatTileInTheChartFormOfTheUser', async () => {
|
|
fetchRoutes({
|
|
...dashboardRoutes({dashboardTiles: [{id: 'STATS_FINISHED', size: 'M'}], statCharts: {FINISHED: 'LINE'}}),
|
|
'GET /api/library/stats': json(200, {trend: null, finished: {finishedBeforeTracking: 0,
|
|
perYear: [{year: 2022, games: 3}, {year: 2024, games: 1}]}}),
|
|
});
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByRole('button', {name: /2022/})).toBeTruthy();
|
|
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
|
|
expect(screen.getByText('2023: 0')).toBeTruthy();
|
|
expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
|
|
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).toBeTruthy();
|
|
});
|
|
|
|
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
|
|
|
it('aTasteTilePickerSavesAndRedraws', async () => {
|
|
fetchRoutes({
|
|
...dashboardRoutes({dashboardTiles: [{id: 'STATS_TASTE', size: 'M'}]}),
|
|
'GET /api/library/stats': json(200, {trend: null, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200},
|
|
{name: 'Puzzle', games: 1, minutes: 300}], moods: []}}),
|
|
'PUT /api/users/settings/stat-chart': json(204, {}),
|
|
});
|
|
const {container} = renderDashboard();
|
|
await screen.findByText('STATS.TASTE.TITLE');
|
|
|
|
fireEvent.click(within(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
|
|
|
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
|
|
expect(putBodies()).toEqual([{group: 'TASTE', chart: 'PIE'}]);
|
|
});
|
|
|
|
it('theMostPlayedCardShowsTheTopGamesForm', async () => {
|
|
const container = mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'L'}], statCharts: {TOP_GAMES: 'DONUT'}});
|
|
|
|
await screen.findByRole('group', {name: 'STATS.CHART.LABEL'});
|
|
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
|
|
const card = container.querySelector('[data-tile="MOST_PLAYED"]');
|
|
expect(within(card).getAllByRole('link', {name: /Game/})).toHaveLength(5);
|
|
expect(within(card).getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
|
|
});
|
|
|
|
it('theActivityPickerSavesTheForm', async () => {
|
|
fetchRoutes({...dashboardRoutes(), 'PUT /api/users/settings/stat-chart': json(204, {})});
|
|
const {container} = renderDashboard();
|
|
await screen.findByText('DASHBOARD.ACTIVITY');
|
|
|
|
const card = container.querySelector('[data-tile="ACTIVITY"]');
|
|
fireEvent.click(within(card).getByRole('button', {name: 'STATS.CHART.LINE'}));
|
|
|
|
await waitFor(() => expect(container.querySelector('polyline')).not.toBeNull());
|
|
expect(putBodies()).toEqual([{group: 'ACTIVITY', chart: 'LINE'}]);
|
|
});
|
|
|
|
it('leaves out a stat tile whose data did not come', async () => {
|
|
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
|
|
|
|
await screen.findByText('Game 0');
|
|
expect(order(container)).toEqual(['MOST_PLAYED']);
|
|
});
|
|
|
|
it('saves the default layout as "never changed"', async () => {
|
|
mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]});
|
|
|
|
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
|
|
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
|
|
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
|
|
|
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true));
|
|
const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT');
|
|
expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/);
|
|
expect(JSON.parse(init.body)).toEqual({tiles: null});
|
|
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
|
|
});
|
|
it('puts "Customize" into the welcome card', async () => {
|
|
mockDashboard();
|
|
|
|
const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
|
|
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
|
|
});
|
|
|
|
it('shows the achievement summary', async () => {
|
|
mockDashboard();
|
|
|
|
expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE')).toBeTruthy();
|
|
expect(screen.getByText('71,3 %')).toBeTruthy();
|
|
});
|
|
|
|
it('shows the picked banner game instead', async () => {
|
|
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
|
|
const container = mockDashboard();
|
|
|
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
|
expect(container.querySelector('img[src$="/620/library_hero.jpg"]')).not.toBeNull();
|
|
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).toBeNull();
|
|
});
|
|
|
|
it('shows no banner when it is turned off', async () => {
|
|
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620, showBanner: false}));
|
|
const container = mockDashboard();
|
|
|
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
|
expect(container.querySelector('img[src*="library_hero"]')).toBeNull();
|
|
});
|
|
it('shows the page when a card answers 200 without a JSON body', async () => {
|
|
// No Steam ID, so no activity and achievements are asked.
|
|
fetchRoutes({
|
|
...quietRoutes,
|
|
'GET /api/library/stats': json(200, empty),
|
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
|
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
|
'GET /api/library/backlog': json(200, {games: []}),
|
|
'GET /api/library/games': (url) => (mostPlayed(url)
|
|
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
|
: json(200, empty)),
|
|
});
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
|
});
|
|
|
|
it('watches the sync the server started for this visit', async () => {
|
|
fetchRoutes({
|
|
...dashboardRoutes(),
|
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, syncStarted: true}),
|
|
});
|
|
renderDashboard();
|
|
|
|
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('loads the activity and the achievements of a Steam user when games-count fails', async () => {
|
|
fetchRoutes({...dashboardRoutes(), 'GET /api/library/games-count': json(500, {})});
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
|
await waitFor(() => expect(asked('/api/library/activity')).toBe(true));
|
|
await waitFor(() => expect(asked('/api/library/achievements/summary')).toBe(true));
|
|
});
|
|
|
|
describe('of a user with a PlayStation link and no Steam ID', () => {
|
|
const psnOnlyRoutes = (canSync) => ({
|
|
...dashboardRoutes(),
|
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', settings: {}}),
|
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: false, canSync}),
|
|
});
|
|
|
|
it('loads the activity, the achievements and the sync result, but no Steam profile', async () => {
|
|
fetchRoutes(psnOnlyRoutes(true));
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
|
await waitFor(() => expect(asked('/api/library/activity')).toBe(true));
|
|
await waitFor(() => expect(asked('/api/library/sync-changes')).toBe(true));
|
|
await waitFor(() => expect(asked('/api/library/achievements/summary')).toBe(true));
|
|
expect(asked('/api/library/steam-profile')).toBe(false);
|
|
});
|
|
|
|
it('shows the achievements card, which counts trophies too', async () => {
|
|
fetchRoutes(psnOnlyRoutes(true));
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.UNLOCKED')).toBeTruthy();
|
|
});
|
|
|
|
it('shows the sync button of the welcome card', async () => {
|
|
fetchRoutes(psnOnlyRoutes(true));
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
|
|
});
|
|
|
|
it('asks for no activity and no sync result without a PlayStation link', async () => {
|
|
fetchRoutes(psnOnlyRoutes(false));
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
|
expect(asked('/api/library/activity')).toBe(false);
|
|
expect(asked('/api/library/sync-changes')).toBe(false);
|
|
expect(asked('/api/library/achievements/summary')).toBe(false);
|
|
expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('does not watch a sync when the server started none', async () => {
|
|
fetchRoutes(dashboardRoutes());
|
|
renderDashboard();
|
|
|
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
|
expect(watchSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|