// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, screen, waitFor} 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}), '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(, {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(, {path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login':

login

}}); 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); 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', '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.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull(); }); 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/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, syncStarted: true}), }); renderDashboard(); await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1)); }); 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(); }); });