perf(library): one dashboard call, stored game detail first
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 28s

The Dashboard loads the top and playing tiles from
GET /api/library/dashboard-games in one call. The game page shows the
stored game first (refresh=false) and calls again only when
achievementsStale is true. The second answer merges the achievement
fields; it takes status and finishedHint only when the user did not
change the status. The points check runs again after the refresh.

Refs #47
This commit is contained in:
lobo committed 2026-10-12 09:25:49 +02:00
1 parent 73ad4fd3b8
commit 0924600d95
10 files changed
+179 -36

No files matched your search

+3 -3
View File
@@ -21,7 +21,7 @@ To Be Played is split into three repositories:
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width - **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games and games in progress (one call, `GET /api/library/dashboard-games`), "Pile of Shame" with backlog hours, its trend of the last 90
days ("this month: +N added, -N off the pile") and a random suggestion, days ("this month: +N added, -N off the pile") and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %. The card counts PlayStation trophies too total, perfect games, average completion and the 5 games closest to 100 %. The card counts PlayStation trophies too
@@ -118,7 +118,7 @@ flowchart LR
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game. next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %. The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: the page shows the stored game at once (`?refresh=false`); when Core says `achievementsStale` a second call refreshes the achievements from Steam and replaces the data. Status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
@@ -295,7 +295,7 @@ flowchart LR
```mermaid ```mermaid
flowchart LR flowchart LR
DetailData["Core GET /api/library/games/{appid}: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"] DetailData["Core GET /api/library/games/{appid}?refresh=false, then without it when achievementsStale: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"]
DetailData --> Hero["GameHeroCard: total playtime"] DetailData --> Hero["GameHeroCard: total playtime"]
Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"] Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"]
``` ```
+4 -3
View File
@@ -12,9 +12,10 @@ const waitingKey = (kind, reason) => (kind === 'FINISHED_TOPUP' ? WAITING_TOPUP.
/** /**
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it * What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
* settles the points; a status change after the first load also shows the points toast. * settles the points; a status change after the first load also shows the points toast. `refreshes` loads it again
* after the Steam refresh of the game page, which may have stored an unlock that settles points.
*/ */
export default function GamePointsLine({game}) { export default function GamePointsLine({game, refreshes = 0}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const check = usePointsCheck(); const check = usePointsCheck();
const [points, setPoints] = useState(null); const [points, setPoints] = useState(null);
@@ -30,7 +31,7 @@ export default function GamePointsLine({game}) {
return () => { return () => {
stale = true; stale = true;
}; };
}, [check, game.appid, game.status]); }, [check, game.appid, game.status, refreshes]);
if (!points) return null; if (!points) return null;
const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'}); const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'});
+8 -9
View File
@@ -3,10 +3,8 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js'; import {request} from '../utils/api.js';
import {PLAYING_GAMES, TOP_GAMES, UP_NEXT_GAMES} from '../utils/tiles.js'; import {PLAYING_GAMES, TOP_GAMES, UP_NEXT_GAMES} from '../utils/tiles.js';
// The games endpoint accepts only the page sizes 20, 40 and 80. const DASHBOARD_GAMES_URL = `/api/library/dashboard-games?top=${TOP_GAMES.L}&playing=${PLAYING_GAMES.L}`;
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const UP_NEXT_URL = `/api/library/backlog?limit=${UP_NEXT_GAMES.L}&released=true`; const UP_NEXT_URL = `/api/library/backlog?limit=${UP_NEXT_GAMES.L}&released=true`;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
// A card whose request fails or has no JSON body stays empty. // A card whose request fails or has no JSON body stays empty.
async function loadInto(path, set, pick = (data) => data) { async function loadInto(path, set, pick = (data) => data) {
@@ -30,8 +28,10 @@ export default function useDashboardData() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []); const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []); const fetchGames = useCallback(() => loadInto(DASHBOARD_GAMES_URL, (data) => {
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []); setTopGames(data.top ?? []);
setPlaying(data.playing ?? []);
}), []);
const fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []); const fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []);
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []); const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []); const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
@@ -50,8 +50,7 @@ export default function useDashboardData() {
const reloadLibrary = () => { const reloadLibrary = () => {
fetchSteamCount(); fetchSteamCount();
fetchTopGames(); fetchGames();
fetchPlaying();
fetchUpNext(); fetchUpNext();
fetchSummary(); fetchSummary();
fetchActivity(); fetchActivity();
@@ -65,7 +64,7 @@ export default function useDashboardData() {
const withHistory = fetchSteamCount().then(async (counts) => { const withHistory = fetchSteamCount().then(async (counts) => {
if (counts == null || counts.canSync) await Promise.all([fetchActivity(), fetchAchievements()]); if (counts == null || counts.canSync) await Promise.all([fetchActivity(), fetchAchievements()]);
}); });
const [{ok, status}] = await Promise.all([reloadUser(), withHistory, fetchTopGames(), fetchPlaying(), const [{ok, status}] = await Promise.all([reloadUser(), withHistory, fetchGames(),
fetchUpNext(), fetchSummary()]); fetchUpNext(), fetchSummary()]);
if (!ok) { if (!ok) {
if (status === 0) setError('INFO.ERROR.NETWORK_FAILURE'); if (status === 0) setError('INFO.ERROR.NETWORK_FAILURE');
@@ -75,7 +74,7 @@ export default function useDashboardData() {
}; };
fetchUserData(); fetchUserData();
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]); }, [reloadUser, fetchSteamCount, fetchGames, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
return { return {
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error, steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
+23 -3
View File
@@ -2,11 +2,17 @@ import {useCallback, useEffect, useState} from 'react';
import {request} from '../utils/api.js'; import {request} from '../utils/api.js';
import useAbortPrevious from './useAbortPrevious.js'; import useAbortPrevious from './useAbortPrevious.js';
// What the Steam refresh changes. The rest of the refreshed answer was read before a save made during the wait.
const ACHIEVEMENT_FIELDS = ['hasAchievements', 'statsVisible', 'achievementsTotal', 'achievementsUnlocked',
'achievementsRemaining', 'completionPercent', 'achievements', 'achievementSets', 'platforms', 'achievementsStale'];
export default function useGame(appid) { export default function useGame(appid) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
// Counts the finished Steam refreshes, so a reader of the stored achievements can look again.
const [refreshes, setRefreshes] = useState(0);
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit // Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
// cards keep what is typed. // cards keep what is typed.
@@ -21,19 +27,33 @@ export default function useGame(appid) {
const signal = nextSignal(); const signal = nextSignal();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
(async () => { (async () => {
const {ok, status, data: json} = await request( const {ok, status, data: json} = await request(`${url}?refresh=false`, {signal});
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
if (signal.aborted) return; if (signal.aborted) return;
setIsLoading(false); setIsLoading(false);
if (status === 401) return; if (status === 401) return;
if (status === 404) setError('GAME_DETAIL.NOT_FOUND'); if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD'); else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
else setData(json); else setData(json);
if (!ok || !json?.achievementsStale) return;
// A failed refresh keeps the stored data, no error.
const refreshed = await request(url, {signal});
if (signal.aborted) return;
if (refreshed.ok && refreshed.data) {
const fresh = Object.fromEntries(ACHIEVEMENT_FIELDS.map((field) => [field, refreshed.data[field]]));
// The finish rule may have set the status during the refresh; keep a status the user changed since.
setData((d) => {
if (!d) return refreshed.data;
const unchanged = d.status === json.status;
return {...d, ...fresh, ...(unchanged && {status: refreshed.data.status, finishedHint: refreshed.data.finishedHint})};
});
}
setRefreshes((n) => n + 1);
})(); })();
}, [nextSignal, appid, reloads]); }, [nextSignal, appid, reloads]);
const reload = useCallback(() => setReloads((n) => n + 1), []); const reload = useCallback(() => setReloads((n) => n + 1), []);
return {data, setData, isLoading, error, reload}; return {data, setData, isLoading, error, reload, refreshes};
} }
+13 -6
View File
@@ -15,7 +15,6 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg'; 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 games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const empty = {content: [], total: 0, byStatus: {}}; const empty = {content: [], total: 0, byStatus: {}};
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
const quietRoutes = { const quietRoutes = {
'GET /api/library/summary': json(200, empty), 'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}), 'GET /api/hints': json(200, {results: []}),
@@ -27,7 +26,7 @@ const quietRoutes = {
const dashboardRoutes = (settings) => ({ const dashboardRoutes = (settings) => ({
...quietRoutes, ...quietRoutes,
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}), '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/dashboard-games': json(200, {top: games, playing: []}),
'GET /api/library/stats': json(200, {trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}), '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/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: [ 'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
@@ -91,13 +90,23 @@ describe('Dashboard', () => {
fetchRoutes({...routes, 'GET /api/users/me': () => meAnswer}); fetchRoutes({...routes, 'GET /api/users/me': () => meAnswer});
const container = renderDashboard().container; const container = renderDashboard().container;
await waitFor(() => expect(asked('/api/library/games')).toBe(true)); await waitFor(() => expect(asked('/api/library/dashboard-games')).toBe(true));
expect(asked('/api/library/summary')).toBe(true); expect(asked('/api/library/summary')).toBe(true);
answerMe(me); answerMe(me);
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull()); await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
}); });
it('asks both game tiles in one call', async () => {
const container = mockDashboard();
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
const gameCalls = fetch.mock.calls.map(([url]) => url).filter((url) => url.includes('dashboard-games'));
expect(gameCalls).toHaveLength(1);
expect(gameCalls[0]).toContain('top=10&playing=8');
expect(asked('/api/library/games?')).toBe(false);
});
it('shows today\'s tiles without a saved layout', async () => { it('shows today\'s tiles without a saved layout', async () => {
const container = mockDashboard(); const container = mockDashboard();
@@ -231,9 +240,7 @@ describe('Dashboard', () => {
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}), '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-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/backlog': json(200, {games: []}), 'GET /api/library/backlog': json(200, {games: []}),
'GET /api/library/games': (url) => (mostPlayed(url) 'GET /api/library/dashboard-games': {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }},
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
: json(200, empty)),
}); });
renderDashboard(); renderDashboard();
+3 -3
View File
@@ -32,7 +32,7 @@ describe('Dashboard sync on a visit', () => {
'GET /api/news': json(200, {results: []}), 'GET /api/news': json(200, {results: []}),
'GET /api/library/sync-changes': () => syncChanges, 'GET /api/library/sync-changes': () => syncChanges,
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}), 'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
'GET /api/library/games': json(200, empty), 'GET /api/library/dashboard-games': json(200, {top: [], playing: []}),
'GET /api/library/stats': json(200, {trend: null}), 'GET /api/library/stats': json(200, {trend: null}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}), 'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}), 'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
@@ -49,7 +49,7 @@ describe('Dashboard sync on a visit', () => {
syncStatus = {lastSyncStatus: 'RUNNING', lastSyncedAt: '2026-10-03T10:00:00Z'}; syncStatus = {lastSyncStatus: 'RUNNING', lastSyncedAt: '2026-10-03T10:00:00Z'};
await wait(3000); await wait(3000);
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy(); expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
const gamesBefore = calls('/api/library/games'); const gamesBefore = calls('/api/library/dashboard-games');
const changesBefore = calls('/api/library/sync-changes'); const changesBefore = calls('/api/library/sync-changes');
syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'}; syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'};
@@ -60,6 +60,6 @@ describe('Dashboard sync on a visit', () => {
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull(); expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
expect(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy(); expect(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy();
expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore); expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore);
expect(calls('/api/library/games')).toBeGreaterThan(gamesBefore); expect(calls('/api/library/dashboard-games')).toBeGreaterThan(gamesBefore);
}); });
}); });
+2 -2
View File
@@ -33,7 +33,7 @@ export default function GameDetail() {
const {appid} = useParams(); const {appid} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const {data, setData, isLoading, error, reload} = useGame(appid); const {data, setData, isLoading, error, reload, refreshes} = useGame(appid);
const [correcting, setCorrecting] = useState(false); const [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [linking, setLinking] = useState(false); const [linking, setLinking] = useState(false);
@@ -96,7 +96,7 @@ export default function GameDetail() {
</p> </p>
)} )}
<GamePointsLine key={`points-${data.appid}`} game={data}/> <GamePointsLine key={`points-${data.appid}`} game={data} refreshes={refreshes}/>
{/* The finished question also answers "are you playing it?". */} {/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && ( {!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
+43
View File
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 0, source: 'STEAM', status: 'FINISHED',
rating: null, notes: null, priority: null, hasAchievements: true, statsVisible: true, achievements: [],
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('checksThePointsAgainAfterTheAchievementRefresh', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true, achievementsUnlocked: 3}),
new Promise((resolve) => { finishRefresh = resolve; })];
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/rewards/games/220': json(200, {balance: 0, missing: {FINISHED: 'NO_PROOF'}, paid: []}),
'GET /api/library/games/220': () => answers.shift(),
});
const pointsCalls = () => fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/220')).length;
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
await screen.findByText('Half-Life 2');
await waitFor(() => expect(pointsCalls()).toBe(1));
// The refresh stored the ending achievement: Core would settle the finish now (the old single call did).
finishRefresh(json(200, {...game, achievementsStale: false, achievementsUnlocked: 5}));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(pointsCalls()).toBe(2);
});
+79 -6
View File
@@ -17,15 +17,17 @@ const game = {
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [], rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
}; };
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length; const gameUrls = () => fetch.mock.calls.map(([url]) => url).filter((url) => new URL(url, 'http://x').pathname.endsWith('/api/library/games/220'));
const gameLoads = () => gameUrls().length;
const gameRoutes = (gameAnswer) => fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': gameAnswer,
});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
fetchRoutes({ gameRoutes(json(200, game));
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': json(200, game),
});
}); });
afterEach(() => { afterEach(() => {
@@ -42,3 +44,74 @@ it('keeps unsaved notes while the game loads again after a correction', async ()
await waitFor(() => expect(gameLoads()).toBe(2)); await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft')); await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
}); });
it('shows the stored game, then the refreshed one when the achievements are stale', async () => {
const answers = [json(200, {...game, achievementsStale: true}),
json(200, {...game, hasAchievements: true, statsVisible: true, achievementsTotal: 1, achievementsUnlocked: 1,
achievements: [{apiName: 'A', displayName: 'Refreshed achievement', unlocked: true, kind: 'STEAM'}],
achievementSets: [{id: 'steam', kind: 'STEAM', total: 1, unlocked: 1, perfect: true, visible: true, listed: true}],
achievementsStale: false})];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Refreshed achievement')).toBeTruthy();
expect(gameUrls()).toHaveLength(2);
expect(gameUrls()[0]).toContain('refresh=false');
expect(gameUrls()[1]).not.toContain('refresh');
});
it('keeps the stored game when the refreshing call fails', async () => {
const answers = [json(200, {...game, achievementsStale: true}), json(500, {})];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Half-Life 2')).toBeTruthy();
await waitFor(() => expect(gameUrls()).toHaveLength(2));
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
});
it('asks once when the achievements are fresh', async () => {
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByText('Half-Life 2')).toBeTruthy();
expect(gameUrls()).toHaveLength(1);
expect(gameUrls()[0]).toContain('refresh=false');
});
// The refreshing call answers the status read before a save made in its wait; only its achievements are taken.
it('keepsAStatusSavedWhileTheAchievementsRefresh', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true}),
new Promise((resolve) => { finishRefresh = resolve; })];
fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': () => answers.shift(),
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
});
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
// Core read the game (BACKLOG) before the save, then waited for Steam.
finishRefresh(json(200, {...game, achievementsStale: false}));
await waitFor(() => expect(gameLoads()).toBe(2));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull();
});
// The finish rule (ending achievement unlocked) sets the status during the refresh.
it('showsTheStatusTheRefreshSetWhenTheUserChangedNothing', async () => {
let finishRefresh;
const answers = [json(200, {...game, achievementsStale: true}),
new Promise((resolve) => { finishRefresh = resolve; })];
gameRoutes(() => answers.shift());
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeTruthy();
finishRefresh(json(200, {...game, status: 'FINISHED', achievementsStale: false}));
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
});
+1 -1
View File
@@ -18,7 +18,7 @@ vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// The last column: what the page loads besides GET /api/users/me of the provider. // The last column: what the page loads besides GET /api/users/me of the provider.
const pages = [ const pages = [
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE', ['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE',
['/api/library/games', '/api/library/games-count', '/api/library/backlog', '/api/library/summary', ['/api/library/dashboard-games', '/api/library/games-count', '/api/library/backlog', '/api/library/summary',
'/api/library/activity', '/api/library/achievements/summary']], '/api/library/activity', '/api/library/achievements/summary']],
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']], ['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']],
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD', ['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD',