Public Access
perf(library): one dashboard call, stored game detail first
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:
1 parent
73ad4fd3b8
commit
0924600d95
10 files changed
+179
-36
No files matched your search
@@ -15,7 +15,6 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
|
||||
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 empty = {content: [], total: 0, byStatus: {}};
|
||||
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
|
||||
const quietRoutes = {
|
||||
'GET /api/library/summary': json(200, empty),
|
||||
'GET /api/hints': json(200, {results: []}),
|
||||
@@ -27,7 +26,7 @@ const quietRoutes = {
|
||||
const dashboardRoutes = (settings) => ({
|
||||
...quietRoutes,
|
||||
'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/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: [
|
||||
@@ -91,13 +90,23 @@ describe('Dashboard', () => {
|
||||
fetchRoutes({...routes, 'GET /api/users/me': () => meAnswer});
|
||||
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);
|
||||
|
||||
answerMe(me);
|
||||
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 () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
@@ -231,9 +240,7 @@ describe('Dashboard', () => {
|
||||
'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/backlog': json(200, {games: []}),
|
||||
'GET /api/library/games': (url) => (mostPlayed(url)
|
||||
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
||||
: json(200, empty)),
|
||||
'GET /api/library/dashboard-games': {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }},
|
||||
});
|
||||
renderDashboard();
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('Dashboard sync on a visit', () => {
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/sync-changes': () => syncChanges,
|
||||
'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/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
|
||||
'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'};
|
||||
await wait(3000);
|
||||
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');
|
||||
|
||||
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(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy();
|
||||
expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore);
|
||||
expect(calls('/api/library/games')).toBeGreaterThan(gamesBefore);
|
||||
expect(calls('/api/library/dashboard-games')).toBeGreaterThan(gamesBefore);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export default function GameDetail() {
|
||||
const {appid} = useParams();
|
||||
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 [merging, setMerging] = useState(false);
|
||||
const [linking, setLinking] = useState(false);
|
||||
@@ -96,7 +96,7 @@ export default function GameDetail() {
|
||||
</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?". */}
|
||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -17,15 +17,17 @@ const game = {
|
||||
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(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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': json(200, game),
|
||||
});
|
||||
gameRoutes(json(200, game));
|
||||
});
|
||||
|
||||
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(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());
|
||||
});
|
||||
@@ -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.
|
||||
const pages = [
|
||||
['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']],
|
||||
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD', ['/api/library/games/220']],
|
||||
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD',
|
||||
|
||||
Reference in new issue
Block a user