From 8b94a25844fbc1bc6c4f524f924b105fd424a416 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 4 Oct 2026 10:07:27 +0200 Subject: [PATCH] fix(stats): open game detail pages from stat lists, perfect games clickable Add perfect games click-through on the dashboard. Remove Steam store links from game lists. Stat numbers with count 0 are not clickable. Tests cover the perfect-games click, zero guards, and owner versus visitor behavior. --- public/i18n/de.json | 1 - public/i18n/en.json | 1 - src/components/AchievementsCard.jsx | 11 +++- src/components/AchievementsCard.test.jsx | 22 ++++++- src/components/PileOfShameCard.jsx | 5 +- src/components/PileOfShameCard.test.jsx | 10 +++ src/components/StatCard.jsx | 2 +- src/components/SteamGameCount.jsx | 5 +- src/components/SteamGameCount.test.jsx | 20 ++++++ src/components/stats/GameRow.jsx | 15 ++--- src/components/stats/HabitsStats.jsx | 2 +- src/components/stats/OverviewStats.jsx | 7 +- src/components/stats/PileStats.jsx | 4 +- src/components/stats/StatDrill.jsx | 16 +++-- src/components/stats/StatDrill.test.jsx | 25 +++++++ src/components/stats/StatGamesDialog.jsx | 4 +- src/components/stats/StatGamesDialog.test.jsx | 27 ++++++++ src/components/stats/statGroupTile.jsx | 2 +- src/pages/LandingPage.jsx | 5 +- src/pages/LandingPage.test.jsx | 18 +++++ src/pages/PointsPage.jsx | 2 +- src/pages/PointsPage.test.jsx | 7 ++ src/pages/PublicProfilePage.jsx | 4 +- src/pages/PublicProfilePage.test.jsx | 65 +++++++++++++++++++ src/pages/StatsPage.test.jsx | 28 ++++++++ src/utils/statGames.js | 6 +- src/utils/statGames.test.js | 7 +- 27 files changed, 270 insertions(+), 51 deletions(-) create mode 100644 src/components/SteamGameCount.test.jsx create mode 100644 src/components/stats/StatDrill.test.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 8880dee..899be1b 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -575,7 +575,6 @@ "TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen", "TREND_DONE": "Diesen Monat vom Stapel gegangen", "NEW_IN_LIBRARY": "Neu", - "OPEN": "Im Steam-Shop öffnen", "GAMES_one": "{{count}} Spiel", "GAMES_other": "{{count}} Spiele" } diff --git a/public/i18n/en.json b/public/i18n/en.json index d9c0d99..634686a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -575,7 +575,6 @@ "TREND_ADDED": "Added to the pile this month", "TREND_DONE": "Off the pile this month", "NEW_IN_LIBRARY": "New", - "OPEN": "Open in the Steam store", "GAMES_one": "{{count}} game", "GAMES_other": "{{count}} games" } diff --git a/src/components/AchievementsCard.jsx b/src/components/AchievementsCard.jsx index b7a66c2..f7d6014 100644 --- a/src/components/AchievementsCard.jsx +++ b/src/components/AchievementsCard.jsx @@ -5,6 +5,7 @@ import {Trophy} from 'lucide-react'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; import FitText from './FitText.jsx'; +import {StatCount} from './stats/StatDrill.jsx'; /** * @param summary the answer of GET /api/library/achievements/summary @@ -15,12 +16,14 @@ export default function AchievementsCard({summary}) { const started = summary.startedGames ?? 0; const pending = summary.pendingGames ?? 0; const hidden = summary.hiddenGames ?? 0; + const perfect = summary.perfectGames ?? 0; // A private profile keeps games pending; the privacy hint explains that better than the pending count. const steamHidesAll = hidden > 0 && started === 0; const stats = [ [(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')], - [(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')], + [perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'), + {params: {key: 'PERFECT'}, count: perfect}], [summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–', t('DASHBOARD.ACHIEVEMENTS.AVERAGE')], ]; @@ -33,9 +36,11 @@ export default function AchievementsCard({summary}) {
- {stats.map(([value, label]) => ( + {stats.map(([value, label, drill]) => (
-
{value}
+
+ {value} +
{label}
))} diff --git a/src/components/AchievementsCard.test.jsx b/src/components/AchievementsCard.test.jsx index 419e18d..d8cfac7 100644 --- a/src/components/AchievementsCard.test.jsx +++ b/src/components/AchievementsCard.test.jsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import AchievementsCard from './AchievementsCard.jsx'; +import {StatDrillProvider} from './stats/StatDrill.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -49,4 +51,22 @@ describe('AchievementsCard', () => { expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy(); }); + + it('opensThePerfectGamesInTheOwnLibrary', async () => { + fetchRoutes({'GET /api/library/stats/games': json(200, { + content: [{appid: -7, name: 'Manual Game'}], page: 0, size: 40, totalElements: 1, totalPages: 1})}); + render(); + + fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.ACHIEVEMENTS.PERFECT: 2'})); + + const link = await screen.findByRole('link', {name: /Manual Game/}); + expect(fetch.mock.calls[0][0]).toContain('key=PERFECT&page=0'); + expect(link.getAttribute('href')).toBe('/library/-7'); + }); + + it('perfectGamesAreNotClickableAtZero', () => { + render(); + + expect(screen.queryByRole('button')).toBeNull(); + }); }); diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index 3856fa1..b7bebe6 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -36,7 +36,7 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
- {backlog.toLocaleString(i18n.language)} @@ -77,7 +77,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
  • {t(`BACKLOG.STATUS.${s}`)} - + {summary.byStatus[s]}
  • diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index 0491577..ffb5850 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -65,6 +65,16 @@ describe('PileOfShameCard', () => { expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy(); }); + it('zeroCountsAreNotClickable', () => { + render( + {}} summary={summary(0)}/> + ); + + expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 0'})).toBeNull(); + expect(screen.queryByRole('link', {name: 'BACKLOG.STATUS.FINISHED: 0'})).toBeNull(); + expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy(); + }); + it('shows the backlog hours when IGDB knows some', () => { renderCard({summary: summary(2, {backlogMinutes: 7440})}); diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 937025d..85bef4f 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -32,7 +32,7 @@ function useCountUp(target, durationMs = 1600) { export default function StatCard({label, value, icon: Icon, suffix, drill}) { const {i18n} = useTranslation(); const animated = useCountUp(value); - const target = useDrillTarget({title: label, ...drill}); + const target = useDrillTarget({title: label, count: value, ...drill}); const card = ( {Icon && ( diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx index 6229c3b..5e1f7bb 100644 --- a/src/components/SteamGameCount.jsx +++ b/src/components/SteamGameCount.jsx @@ -6,6 +6,7 @@ import {ArrowRight, Gamepad2} from 'lucide-react'; import {Card} from '@/components/ui/card'; const DURATION_MS = 1000; +const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums'; const SteamGameCount = ({targetCount}) => { const {t} = useTranslation(); @@ -33,7 +34,9 @@ const SteamGameCount = ({targetCount}) => {
    - {count} + {targetCount > 0 + ? {count} + : {count}}
    {t('DASHBOARD.STEAM_GAMES')}
    diff --git a/src/components/SteamGameCount.test.jsx b/src/components/SteamGameCount.test.jsx new file mode 100644 index 0000000..d082c2a --- /dev/null +++ b/src/components/SteamGameCount.test.jsx @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import SteamGameCount from './SteamGameCount.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +describe('SteamGameCount', () => { + afterEach(cleanup); + + it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => { + const {container, rerender} = render(); + expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(1); + + rerender(); + expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2); + expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy(); + }); +}); diff --git a/src/components/stats/GameRow.jsx b/src/components/stats/GameRow.jsx index a6b0246..5fc8d31 100644 --- a/src/components/stats/GameRow.jsx +++ b/src/components/stats/GameRow.jsx @@ -1,15 +1,13 @@ import {Link} from 'react-router-dom'; -import {useTranslation} from 'react-i18next'; import GameCover from '../GameCover.jsx'; import FitText from '../FitText.jsx'; import {gameHref} from '../../utils/statGames.js'; /** - * A game with its cover and a value on the right; links to the game page in the user's own library, to the Steam - * store for a visitor. + * A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain + * otherwise. */ export default function GameRow({game, value, linked = true}) { - const {t} = useTranslation(); const content = ( <> @@ -17,14 +15,11 @@ export default function GameRow({game, value, linked = true}) { {value} ); - const className = 'flex items-center gap-3 hover:text-primary transition-colors'; - if (linked) return {content}; - const href = gameHref(game.appid, false); - if (href) { + if (linked) { return ( - + {content} - + ); } return
    {content}
    ; diff --git a/src/components/stats/HabitsStats.jsx b/src/components/stats/HabitsStats.jsx index 2ab4d74..527be0d 100644 --- a/src/components/stats/HabitsStats.jsx +++ b/src/components/stats/HabitsStats.jsx @@ -25,7 +25,7 @@ export default function HabitsStats({habits}) { + drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/> )}
    diff --git a/src/components/stats/OverviewStats.jsx b/src/components/stats/OverviewStats.jsx index cebf363..a9b144f 100644 --- a/src/components/stats/OverviewStats.jsx +++ b/src/components/stats/OverviewStats.jsx @@ -15,18 +15,19 @@ export default function OverviewStats({overview}) {
    + drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/> + drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/> + drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
      {GAME_STATUSES.map((s) => (
    • {t(`BACKLOG.STATUS.${s}`)}{' '} {n(overview.byStatus[s] ?? 0)} diff --git a/src/components/stats/PileStats.jsx b/src/components/stats/PileStats.jsx index b60df7b..e5d1866 100644 --- a/src/components/stats/PileStats.jsx +++ b/src/components/stats/PileStats.jsx @@ -18,10 +18,10 @@ export default function PileStats({pile, linked = true}) {
      + drill={{to: libraryLink('BACKLOG'), params: {key: 'PILE'}, count: pile.backlogGames}}/> + drill={{params: {key: 'PILE_TIME_TO_BEAT'}, count: pile.gamesWithTimeToBeat}}/>
      {pile.normalMinutes > 0 && ( diff --git a/src/components/stats/StatDrill.jsx b/src/components/stats/StatDrill.jsx index 21842b7..04fdc52 100644 --- a/src/components/stats/StatDrill.jsx +++ b/src/components/stats/StatDrill.jsx @@ -15,8 +15,9 @@ const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max * * @param source own, profile, community or points * @param ticket profile: the ticket for the visitor's requests + * @param linked the games are the viewer's own: rows link to the detail page */ -export function StatDrillProvider({source, ticket, children}) { +export function StatDrillProvider({source, ticket, linked = false, children}) { const [request, setRequest] = useState(null); const opener = useRef(null); useEffect(() => { @@ -26,9 +27,9 @@ export function StatDrillProvider({source, ticket, children}) { source, open: (title, params) => { opener.current = document.activeElement; - setRequest({title, source, ticket, params}); + setRequest({title, source, ticket, linked, params}); }, - }), [source, ticket]); + }), [source, ticket, linked]); return ( {children} @@ -43,9 +44,11 @@ export const useStatDrill = () => useContext(StatDrillContext); /** * What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null). + * Nothing at `count` 0; `count` undefined means no count is known. */ -export function useDrillTarget({to, params, title}) { +export function useDrillTarget({to, params, title, count}) { const drill = useStatDrill(); + if (count === 0) return null; if (to && drill?.source === 'own') return {to}; if (drill && params) return {onClick: () => drill.open(title, params)}; return null; @@ -80,9 +83,10 @@ export function DrillButton({onClick, label, className, children}) { * * @param to library URL, used instead of the dialog in the user's own views * @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request + * @param count the number shown; 0 is never clickable */ -export function StatCount({to, params, title, label, className, children}) { - const target = useDrillTarget({to, params, title}); +export function StatCount({to, params, title, label, count, className, children}) { + const target = useDrillTarget({to, params, title, count}); if (!target) return children; return target.to ? {children} diff --git a/src/components/stats/StatDrill.test.jsx b/src/components/stats/StatDrill.test.jsx new file mode 100644 index 0000000..3704e37 --- /dev/null +++ b/src/components/stats/StatDrill.test.jsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import {StatCount, StatDrillProvider} from './StatDrill.jsx'; + +vi.mock('react-i18next', () => import('../../test/i18n-mock.js')); + +const renderCount = (count) => render( + + {count} + ); + +describe('StatCount', () => { + afterEach(cleanup); + + it('aZeroCountIsNotClickable', () => { + renderCount(0); + expect(screen.queryByRole('button')).toBeNull(); + + cleanup(); + renderCount(3); + expect(screen.getByRole('button')).toBeTruthy(); + }); +}); diff --git a/src/components/stats/StatGamesDialog.jsx b/src/components/stats/StatGamesDialog.jsx index 21588c6..dbd3971 100644 --- a/src/components/stats/StatGamesDialog.jsx +++ b/src/components/stats/StatGamesDialog.jsx @@ -15,7 +15,7 @@ const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'}; /** * The games behind one number, 40 per page. * - * @param request `{title, source, ticket, params}` or null (closed) + * @param request `{title, source, ticket, linked, params}` or null (closed) */ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) { const {t, i18n} = useTranslation(); @@ -75,7 +75,7 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
        {state.games.map((game, index) => (
      • - +
      • ))}
      diff --git a/src/components/stats/StatGamesDialog.test.jsx b/src/components/stats/StatGamesDialog.test.jsx index 88d1aa2..638181d 100644 --- a/src/components/stats/StatGamesDialog.test.jsx +++ b/src/components/stats/StatGamesDialog.test.jsx @@ -128,4 +128,31 @@ describe('StatGamesDialog', () => { expect(screen.queryByText('Old game')).toBeNull(); expect(screen.getByText('New game')).toBeTruthy(); }); + + it('aProfileRowIsPlainForAVisitor', async () => { + fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)}); + renderDialog({source: 'profile', ticket: 't'}); + + await screen.findByText('Game 1'); + + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('aProfileRowOpensTheDetailPageForTheOwner', async () => { + fetchRoutes({'GET /api/public/profiles/library/stats/games': page(rows(1, 1), 0, 1, 1)}); + renderDialog({source: 'profile', ticket: 't', linked: true}); + + const link = await screen.findByRole('link', {name: /Game 1/}); + + expect(link.getAttribute('href')).toBe('/library/1'); + }); + + it('aCommunityRowIsPlain', async () => { + fetchRoutes({'GET /api/stats/public/games': page(rows(1, 1, {libraries: 2, minutes: 60}), 0, 1, 1)}); + renderDialog({source: 'community'}); + + await screen.findByText('Game 1'); + + expect(screen.queryByRole('link')).toBeNull(); + }); }); diff --git a/src/components/stats/statGroupTile.jsx b/src/components/stats/statGroupTile.jsx index 02f857f..fb011b4 100644 --- a/src/components/stats/statGroupTile.jsx +++ b/src/components/stats/statGroupTile.jsx @@ -12,7 +12,7 @@ import PlatformStats from './PlatformStats.jsx'; * null tile, and an element is never null. * * @param stats the answer of GET /api/library/stats, or the stats of a public profile - * @param linked the game rows link to the game pages; false on the public profile + * @param linked true for the user's own games: the dashboard and the owner on the profile */ export default function statGroupTile(id, stats, linked = true) { switch (id) { diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 2af4486..9ca85f4 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -17,7 +17,6 @@ import {TagBars} from '../components/stats/TasteStats.jsx'; import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx'; import useSession from '../hooks/useSession.js'; import {genreKey} from '../utils/stats.js'; -import {gameHref} from '../utils/statGames.js'; import usePublicStats from '../hooks/usePublicStats.js'; @@ -51,11 +50,9 @@ function RankedGames({title, games, formatValue, drillKey}) { } function GameName({game}) { - const href = gameHref(game.appid, false); - const name = ; return ( - {href ? {name} : name} + ); } diff --git a/src/pages/LandingPage.test.jsx b/src/pages/LandingPage.test.jsx index 55a926e..482e532 100644 --- a/src/pages/LandingPage.test.jsx +++ b/src/pages/LandingPage.test.jsx @@ -43,6 +43,15 @@ describe('LandingPage', () => { } }); + it('topGamesHaveNoStoreLink', async () => { + answer({topPlayedGames: [{appid: 220, name: 'Half-Life 2', value: 600}]}); + const {container} = renderPage(); + + await screen.findByText('Half-Life 2'); + + expect(container.querySelector('a[href*="steampowered"]')).toBeNull(); + }); + it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => { fetchRoutes({ 'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}), @@ -58,4 +67,13 @@ describe('LandingPage', () => { expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull(); expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull(); }); + + it('aZeroTotalIsNotClickable', async () => { + answer({totalLibraryEntries: 0}); + renderPage(); + + await screen.findByText('LANDING.TOTAL_GAMES'); + + expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull(); + }); }); diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index a7fb069..961e718 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -203,7 +203,7 @@ function LockedProgress({item, progress}) { const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target}); // Playtime months compare two numbers: no list of games shows them. const target = useDrillTarget({ - params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), + params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), count, }); const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground'; const Wrapper = target ? 'button' : 'div'; diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx index a4d152c..0ae372e 100644 --- a/src/pages/PointsPage.test.jsx +++ b/src/pages/PointsPage.test.jsx @@ -121,6 +121,13 @@ describe('PointsPage', () => { 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(); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 9267167..293f82b 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -138,7 +138,7 @@ export default function PublicProfilePage() { && ; case 'FINISHED': return library.finished?.length > 0 && ; - default: return statGroupTile(id, library.stats, false); + default: return statGroupTile(id, library.stats, owner); } }; // Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load. @@ -178,7 +178,7 @@ export default function PublicProfilePage() { )} - + setEditing(false)}/> diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index e89c2d2..7eab23e 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -290,4 +290,69 @@ describe('PublicProfilePage', () => { delete library.stats; } }); + + const gamesRoute = {'GET /api/public/profiles/library/stats/games': json(200, { + content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}; + const topGamesStats = {pile: null, trend: null, taste: null, finished: null, habits: null, + topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], mostLoyal: null}}; + + it('theOwnerOpensTheDetailPageFromTheList', async () => { + localStorage.setItem('tbp_session', '1'); + try { + mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute}); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'})); + + expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1'); + } finally { + localStorage.clear(); + } + }); + + it('theOwnerTileRowsOpenTheDetailPage', async () => { + localStorage.setItem('tbp_session', '1'); + library.stats = topGamesStats; + try { + mockBackends({me: {displayName: 'lobo', role: 'USER'}, style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}}); + renderPage(); + + expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620'); + } finally { + delete library.stats; + localStorage.clear(); + } + }); + + it('aVisitorSeesTileRowsWithoutLinks', async () => { + library.stats = topGamesStats; + try { + mockBackends({style: {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}]}}); + const {container} = renderPage(); + + await screen.findByText('Portal 2'); + + expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull(); + expect(container.querySelector('a[href*="steampowered"]')).toBeNull(); + } finally { + delete library.stats; + } + }); + + it('opensTheGamesBehindPerfectGames', async () => { + library.perfectGames = 12; + library.averageCompletion = 64.5; + try { + mockBackends({extra: gamesRoute}); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: /PROFILE\.PERFECT_GAMES: 12/})); + + await screen.findByText('Backlogged'); + expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=PERFECT'); + } finally { + delete library.perfectGames; + delete library.averageCompletion; + } + }); }); diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index 53fbcae..edbf40b 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -113,6 +113,34 @@ describe('StatsPage', () => { expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG'); }); + it('aZeroTileIsNotClickable', async () => { + const base = stats(); + answerStats(json(200, stats({overview: {...base.overview, neverStarted: 0}}))); + renderPage(); + + await screen.findByText('STATS.OVERVIEW.NEVER_STARTED'); + + expect(screen.queryByRole('button', {name: /STATS\.OVERVIEW\.NEVER_STARTED/})).toBeNull(); + }); + + it('zeroCountsAreNotClickableInPileHabitsAndStatuses', async () => { + const base = stats(); + answerStats(json(200, stats({ + overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}}, + pile: {...base.pile, backlogGames: 0, gamesWithTimeToBeat: 0}, + habits: {...base.habits, underOneHourGames: 0}, + }))); + renderPage(); + + await screen.findByText('STATS.PILE.GAMES'); + + for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED: 0/]) { + expect(screen.queryByRole('button', {name})).toBeNull(); + expect(screen.queryByRole('link', {name})).toBeNull(); + } + expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy(); + }); + it('leavesTheMoonTripsAlone', async () => { answerStats(json(200, stats())); renderPage(); diff --git a/src/utils/statGames.js b/src/utils/statGames.js index c777ae9..db3db7d 100644 --- a/src/utils/statGames.js +++ b/src/utils/statGames.js @@ -47,8 +47,4 @@ export async function fetchStatGames(source, params, ticket) { export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`; -// A visitor has no game page of the owner; the Steam store is the only public one. Manual games have no appid there. -export const gameHref = (appid, own) => { - if (own) return `/library/${appid}`; - return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null; -}; +export const gameHref = (appid) => `/library/${appid}`; diff --git a/src/utils/statGames.test.js b/src/utils/statGames.test.js index fa8941a..2b9b9d2 100644 --- a/src/utils/statGames.test.js +++ b/src/utils/statGames.test.js @@ -27,9 +27,8 @@ describe('libraryLink', () => { }); describe('gameHref', () => { - it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5')); - it('visitor_LinksTheStoreOnlyForSteamApps', () => { - expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5'); - expect(gameHref(-3, false)).toBeNull(); + it('linksTheLibraryPage', () => { + expect(gameHref(5)).toBe('/library/5'); + expect(gameHref(-3)).toBe('/library/-3'); }); });