Public Access
feat(psn): show trophy sets on game detail and dashboard
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:
1 parent
27c402fa34
commit
e7b08cfb47
14 files changed
+349
-105
No files matched your search
@@ -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) ?? []}/>
|
||||
));
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}/>}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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});
|
||||
|
||||
Reference in new issue
Block a user