feat(psn): show trophy sets on game detail and dashboard
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Build, Test & Push Frontend / docker (push) Successful in 33s

Detail page groups achievements by set: Steam, then PSN trophies.
Tiles show the real count; a perfect game gets an amber trophy.
Dashboard card shows for PSN-only users; CSP allows PSN icons.
This commit is contained in:
lobo committed 2026-10-08 09:18:15 +02:00
1 parent 27c402fa34
commit e7b08cfb47
14 files changed
+349 -105

No files matched your search

+143 -67
View File
@@ -1,12 +1,21 @@
import {useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import {ShieldOff, Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {formatDate} from '../utils/format.js';
const STEAM_SET = 'steam';
const TROPHY_COLORS = {
BRONZE: 'text-amber-700',
SILVER: 'text-slate-400',
GOLD: 'text-yellow-500',
PLATINUM: 'text-cyan-400',
};
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
try {
@@ -16,93 +25,160 @@ const formatUnlockedAt = (instant, locale) => {
}
};
function AchievementIcon({appid, src, unlocked}) {
// Only Steam icons can be repaired: the broken-image report goes to the Steam icon store.
function AchievementIcon({appid, src, unlocked, steam}) {
const [failed, setFailed] = useState(false);
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
return (
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
onError={() => {
reportBrokenImage(appid, ACHIEVEMENT_ICONS);
if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS);
setFailed(true);
}}/>
);
}
export default function GameAchievements({game}) {
const {t, i18n} = useTranslation();
// The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first.
function sortAchievements(achievements) {
return [...achievements].sort((a, b) => {
if ((a.group ?? '') !== (b.group ?? '')) return (a.group ?? '').localeCompare(b.group ?? '');
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
if (a.unlocked && b.unlocked) {
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
}
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
});
}
const sortedAchievements = useMemo(() => {
if (!game.achievements) return [];
return [...game.achievements].sort((a, b) => {
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
if (a.unlocked && b.unlocked) {
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
}
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
});
}, [game.achievements]);
function SetHeader({set}) {
const {t} = useTranslation();
const steam = set.id === STEAM_SET;
return (
<div className="flex items-baseline justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 text-lg font-semibold">
<Trophy className="size-4 shrink-0 text-amber-500"/>
<span className="truncate">
{steam ? t('GAME_DETAIL.ACHIEVEMENTS') : t('GAME_DETAIL.TROPHIES', {platform: set.platform})}
</span>
</h2>
<span className="text-sm text-muted-foreground tabular-nums">{set.unlocked} / {set.total}</span>
</div>
);
}
function SetSummary({set}) {
const {t} = useTranslation();
const percent = set.perfect ? 100 : set.total === 0 ? 0 : Math.round((set.unlocked * 1000) / set.total) / 10;
const stats = [
[t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked],
[t('GAME_DETAIL.REMAINING'), game.achievementsRemaining],
[t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`],
[t('GAME_DETAIL.UNLOCKED'), set.unlocked],
[t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)],
[t('GAME_DETAIL.COMPLETION'), `${percent}%`],
];
return (
<>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all"
style={{width: `${Math.min(100, percent)}%`}}/>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</>
);
}
function AchievementItem({game, achievement}) {
const {t, i18n} = useTranslation();
const steam = achievement.kind !== 'PSN';
const icon = achievement.unlocked ? achievement.iconUrl : achievement.iconGrayUrl;
const name = achievement.displayName
?? (achievement.hidden && !steam ? t('GAME_DETAIL.HIDDEN_TROPHY') : achievement.apiName);
const trophyLabel = achievement.trophyType && t(`GAME_DETAIL.TROPHY_TYPE.${achievement.trophyType}`);
return (
<li className={cn('flex items-start gap-3 rounded-xl border bg-card p-3',
achievement.unlocked ? 'border-primary/40' : 'opacity-60')}>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={achievement.unlocked} steam={steam}/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{achievement.trophyType && (
<Trophy className={cn('size-3.5 shrink-0', TROPHY_COLORS[achievement.trophyType])}
aria-label={trophyLabel}>
<title>{trophyLabel}</title>
</Trophy>
)}
<div className="truncate font-medium">{name}</div>
{achievement.group && (
<span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-semibold">
{t('GAME_DETAIL.DLC')}
</span>
)}
</div>
{achievement.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{achievement.description}</div>
)}
{achievement.rarity != null && (
<div className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('GAME_DETAIL.RARITY', {percent: Math.round(achievement.rarity * 10) / 10})}
</div>
)}
{achievement.unlocked && achievement.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(achievement.unlockedAt, i18n.language)}
</div>
)}
</div>
</li>
);
}
function AchievementSet({game, set, achievements}) {
const {t} = useTranslation();
const sorted = useMemo(() => sortAchievements(achievements), [achievements]);
const steam = set.id === STEAM_SET;
if (steam && !set.visible) {
return (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
);
}
return (
<>
<Card className="p-6 gap-5">
<div className="flex items-baseline justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('GAME_DETAIL.ACHIEVEMENTS')}
</h2>
<span className="text-sm text-muted-foreground tabular-nums">
{game.achievementsUnlocked} / {game.achievementsTotal}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{width: `${Math.min(100, game.completionPercent)}%`}}
/>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
<SetHeader set={set}/>
{!steam && set.name && <p className="-mt-3 truncate text-sm text-muted-foreground">{set.name}</p>}
<SetSummary set={set}/>
{!set.listed && <p className="text-sm text-muted-foreground">{t('GAME_DETAIL.TROPHIES_PENDING')}</p>}
</Card>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{sortedAchievements.map((a) => {
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
return (
<li
key={a.apiName}
className={cn(
'flex items-start gap-3 rounded-xl border bg-card p-3',
a.unlocked ? 'border-primary/40' : 'opacity-60',
)}
>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={a.unlocked}/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
{a.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
)}
{a.unlocked && a.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(a.unlockedAt, i18n.language)}
</div>
)}
</div>
</li>
);
})}
{sorted.map((achievement) => (
<AchievementItem key={`${achievement.setId ?? STEAM_SET}:${achievement.apiName}`} game={game}
achievement={achievement}/>
))}
</ul>
</>
);
}
export default function GameAchievements({game}) {
const bySet = useMemo(() => {
const groups = new Map();
for (const a of game.achievements ?? []) {
const id = a.setId ?? STEAM_SET;
if (!groups.has(id)) groups.set(id, []);
groups.get(id).push(a);
}
return groups;
}, [game.achievements]);
return (game.achievementSets ?? []).map((set) => (
<AchievementSet key={set.id} game={game} set={set} achievements={bySet.get(set.id) ?? []}/>
));
}
+113
View File
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import GameAchievements from './GameAchievements.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../utils/brokenImages.js', () => ({ACHIEVEMENT_ICONS: 'ACHIEVEMENT_ICONS', reportBrokenImage: vi.fn()}));
const set = (id, extra = {}) => ({id, kind: id === 'steam' ? 'STEAM' : 'PSN', platform: null, name: null, total: 2,
unlocked: 1, perfect: false, visible: true, listed: true, ...extra});
const steam = (apiName, extra = {}) => ({apiName, displayName: `Steam ${apiName}`, unlocked: false, kind: 'STEAM',
setId: null, iconUrl: 'https://cdn.steamstatic.com/a.jpg', iconGrayUrl: 'https://cdn.steamstatic.com/b.jpg', ...extra});
const trophy = (setId, apiName, extra = {}) => ({apiName, displayName: `Trophy ${setId} ${apiName}`, unlocked: false,
kind: 'PSN', setId, trophyType: 'BRONZE', iconUrl: 'https://psnobj.prod.dl.playstation.net/t.png',
iconGrayUrl: 'https://psnobj.prod.dl.playstation.net/t.png', ...extra});
const renderGame = (game) => render(<GameAchievements game={{appid: 220, ...game}}/>);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('GameAchievements', () => {
it('shows one card per set in the order of achievementSets', () => {
renderGame({
achievementSets: [set('steam'), set('NPWR1_00', {platform: 'PS5'}), set('NPWR2_00', {platform: 'PS4'})],
achievements: [trophy('NPWR2_00', '1'), steam('A'), trophy('NPWR1_00', '1')],
});
const headings = screen.getAllByRole('heading', {level: 2}).map((heading) => heading.textContent);
expect(headings).toEqual(['GAME_DETAIL.ACHIEVEMENTS', 'GAME_DETAIL.TROPHIES', 'GAME_DETAIL.TROPHIES']);
const names = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(names[0]).toContain('Steam A');
expect(names[1]).toContain('Trophy NPWR1_00 1');
expect(names[2]).toContain('Trophy NPWR2_00 1');
});
it('lists the base game first and the DLC groups after it, whatever the earned state', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 3, unlocked: 1})],
achievements: [trophy('NPWR1_00', '1', {group: '002', unlocked: true, unlockedAt: '2026-01-02T00:00:00Z'}),
trophy('NPWR1_00', '2', {group: '001'}), trophy('NPWR1_00', '3', {group: null})],
});
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
expect.stringContaining('Trophy NPWR1_00 3'), expect.stringContaining('Trophy NPWR1_00 2'),
expect.stringContaining('Trophy NPWR1_00 1')]);
});
it('titles the trophy type and marks DLC trophies and the rarity', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {trophyType: 'GOLD', group: '001', rarity: 12.34})],
});
expect(screen.getByTitle('GAME_DETAIL.TROPHY_TYPE.GOLD')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.DLC')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.RARITY')).toBeTruthy();
});
it('shows a hidden trophy that is not earned without a name', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {displayName: null, description: null, hidden: true})],
});
expect(screen.getByText('GAME_DETAIL.HIDDEN_TROPHY')).toBeTruthy();
});
it('shows the hidden-stats text for a Steam set Steam hides, and still lists the PlayStation set', () => {
renderGame({
achievementSets: [set('steam', {visible: false, unlocked: 0}), set('NPWR1_00', {platform: 'PS5'})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
expect(screen.getByText('GAME_DETAIL.STATS_HIDDEN')).toBeTruthy();
expect(screen.queryByText('Steam A')).toBeNull();
expect(screen.getByText('Trophy NPWR1_00 1')).toBeTruthy();
});
it('shows the pending text for a trophy set whose list is not stored yet', () => {
renderGame({achievementSets: [set('NPWR1_00', {platform: 'PS4', listed: false})], achievements: []});
expect(screen.getByText('GAME_DETAIL.TROPHIES_PENDING')).toBeTruthy();
});
it('counts a perfect set as 100 percent, also with trophies missing', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 50, unlocked: 30, perfect: true})],
achievements: [],
});
expect(screen.getByText('100%')).toBeTruthy();
expect(screen.getByText('30 / 50')).toBeTruthy();
});
it('reports a broken Steam icon but not a broken PlayStation icon', () => {
const {container} = renderGame({
achievementSets: [set('steam', {total: 1, unlocked: 0}), set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
const steamItem = screen.getByText('Steam A').closest('li');
const psnItem = screen.getByText('Trophy NPWR1_00 1').closest('li');
fireEvent.error(psnItem.querySelector('img'));
expect(reportBrokenImage).not.toHaveBeenCalled();
fireEvent.error(steamItem.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('img')).toHaveLength(0);
});
});
+3 -2
View File
@@ -21,8 +21,9 @@ describe('Content Security Policy in nginx.conf', () => {
const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1]));
// Only links: a navigation needs no img-src.
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures.
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com']
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures, PlayStation trophy icons.
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com',
'psnobj.prod.dl.playstation.net']
.forEach((host) => hosts.add(host));
for (const host of hosts) {
+3 -3
View File
@@ -62,11 +62,11 @@ export default function useDashboardData() {
const fetchUserData = async () => {
const {ok, status, data} = await reloadUser();
if (ok) {
// Games added by hand need no Steam ID; achievements come only from Steam. The history also comes
// from a PlayStation link, which games-count reports as canSync.
// Games added by hand need no Steam ID. The history and the achievements (Steam achievements,
// PlayStation trophies) also come from a PlayStation link, which games-count reports as canSync.
const [counts] = await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(),
fetchSummary(), ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
if (!data.steamId && counts?.canSync) await fetchActivity();
if (!data.steamId && counts?.canSync) await Promise.all([fetchActivity(), fetchAchievements()]);
} else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE');
} else if (status !== 401) {
+1 -1
View File
@@ -129,7 +129,7 @@ const Dashboard = () => {
</Card>
);
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, charts, change);
}
};
+10 -2
View File
@@ -242,17 +242,24 @@ describe('Dashboard', () => {
});
const asked = (path) => fetch.mock.calls.some(([url]) => url.includes(path));
it('loads the activity and the sync result, but not the achievements', async () => {
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));
expect(asked('/api/library/achievements/summary')).toBe(false);
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();
@@ -267,6 +274,7 @@ describe('Dashboard', () => {
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();
});
});
+2 -9
View File
@@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, EyeOff, ShieldOff, Trophy} from 'lucide-react';
import {ArrowLeft, EyeOff, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
@@ -122,14 +122,7 @@ export default function GameDetail() {
</Card>
)}
{data.hasAchievements && !data.statsVisible && (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
)}
{data.hasAchievements && data.statsVisible && <GameAchievements game={data}/>}
{data.hasAchievements && <GameAchievements game={data}/>}
</>
)}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
+17 -2
View File
@@ -129,9 +129,11 @@ describe('GameDetail', () => {
it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
iconGrayUrl: `https://x/${apiName}_gray.jpg`, kind: 'STEAM', setId: null});
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
completionPercent: 0, achievements: [achievement('A'), achievement('B')],
achievementSets: [{id: 'steam', kind: 'STEAM', total: 2, unlocked: 0, perfect: false, visible: true, listed: true}]},
{'POST /api/library/broken-images': json(204)});
const {container} = renderDetail();
await screen.findByText('A');
@@ -144,6 +146,19 @@ describe('GameDetail', () => {
expect(container.querySelector('img[src^="https://x/"]')).toBeNull();
});
it('shows the trophies of a PlayStation game and not the no-achievements card', async () => {
answer({...game, source: 'PSN', hasAchievements: true, statsVisible: true,
achievements: [{apiName: '0', displayName: 'Legend', unlocked: true, kind: 'PSN', setId: 'NPWR1_00',
trophyType: 'PLATINUM', iconUrl: 'https://psnobj.prod.dl.playstation.net/0.png'}],
achievementSets: [{id: 'NPWR1_00', kind: 'PSN', platform: 'PS5', name: 'Ghost', total: 1, unlocked: 1,
perfect: true, visible: true, listed: true}]});
renderDetail();
expect(await screen.findByText('Legend')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.TROPHIES')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.NO_ACHIEVEMENTS')).toBeNull();
});
it('links the speedrun.com page when Core found one', async () => {
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
renderDetail();
+2 -1
View File
@@ -14,6 +14,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {cn} from '@/lib/utils';
import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx';
@@ -323,7 +324,7 @@ export default function GameLibrary() {
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className="size-3"/>
<Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
+8
View File
@@ -87,6 +87,14 @@ describe('GameLibrary search', () => {
expect(screen.queryByText(/\/5$/)).toBeNull();
});
it('shows the real count of a game with a platinum trophy and marks it perfect', async () => {
answer([{...page.content[0], achievementsUnlocked: 30, achievementsTotal: 50, achievementsPerfect: true}]);
renderLibrary();
const badge = (await screen.findByText('30/50')).closest('[data-slot="badge"]');
expect(badge.querySelector('svg.text-amber-500')).not.toBeNull();
});
it('shows the newest search when an older answer comes last', async () => {
const later = [];
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});