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
@@ -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