feat(follow): add feed links, following page and tile (#52, #51)
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s

Feed links go to the game and to the profile view of that game.
Add /following page, nav item and an opt-in Following tile.
Games not in your library show a catalog view with an add button.
This commit is contained in:
lobo committed 2026-10-14 16:23:11 +02:00
1 parent 720764bf68
commit 5be0d42bca
31 files changed
+692 -67

No files matched your search

+3 -1
View File
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import AchievementsCard from '../components/AchievementsCard.jsx';
import ActivityCard from '../components/ActivityCard.jsx';
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import FollowingCard from '../components/FollowingCard.jsx';
import DashboardHint from '../components/DashboardHint.jsx';
import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
import MostPlayedCard from '../components/MostPlayedCard.jsx';
@@ -30,7 +31,7 @@ import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
import {
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, FOLLOWING_SHOWN, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
} from '../utils/tiles.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
@@ -134,6 +135,7 @@ const Dashboard = () => {
);
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
case 'FOLLOWING': return <FollowingCard limit={FOLLOWING_SHOWN[size]}/>;
default: return statGroupTile(id, stats.stats, true, charts, change);
}
};
+16
View File
@@ -72,6 +72,22 @@ describe('Dashboard', () => {
return renderDashboard().container;
};
it('showsTheFollowingTileFromTheSavedLayoutAndHidesItByDefault', async () => {
const routes = dashboardRoutes({dashboardTiles: [{id: 'FOLLOWING', size: 'M'}]});
fetchRoutes({...routes, 'GET /api/follows': json(200, {followers: [],
following: [{id: 1, displayName: 'Anna', lastActivityAt: null}]})});
renderDashboard();
expect(await screen.findByRole('link', {name: 'Anna'})).toBeTruthy();
cleanup();
fetchRoutes(dashboardRoutes());
renderDashboard();
await screen.findByText('DASHBOARD.CURRENTLY_PLAYING');
expect(fetch.mock.calls.some(([url]) => String(url).includes('/api/follows'))).toBe(false);
});
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
const container = mockDashboard();
+30
View File
@@ -0,0 +1,30 @@
import {useTranslation} from 'react-i18next';
import {Loader2, UserCheck} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import FollowingList from '../components/people/FollowingList.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useFollowing from '../hooks/useFollowing.js';
/**
* The people the user follows, newest activity first. The followers stay in the settings.
*/
export default function FollowingPage() {
const {t} = useTranslation();
const {following, failed, unfollow} = useFollowing();
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><UserCheck className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{!failed && !following && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{following && <FollowingList entries={following} onUnfollow={unfollow}/>}
</CardContent>
</Card>
</PageContainer>
);
}
+60
View File
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import FollowingPage from './FollowingPage.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: {success: vi.fn(), error: vi.fn()}}));
afterEach(cleanup);
const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt});
describe('FollowingPage', () => {
it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [
person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})});
renderWithProviders(<FollowingPage/>);
await screen.findByRole('link', {name: 'Cleo'});
expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Cleo', 'Bert', 'Anna']);
expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo');
expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2);
expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy();
});
it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => {
let list = [person(5, 'Anna', null), person(6, 'Bert', null)];
const mock = fetchRoutes({
'GET /api/follows': () => json(200, {followers: [], following: list}),
'DELETE /api/follows/5': () => {
list = [list[1]];
return json(204);
},
});
renderWithProviders(<FollowingPage/>);
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]);
await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull());
expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy();
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
});
it('saysHowToFollowWhenThereIsNobody', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
renderWithProviders(<FollowingPage/>);
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
});
it('showsAnErrorWhenTheListFails', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
renderWithProviders(<FollowingPage/>);
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
});
});
+10 -6
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import BacklogCard from '../components/BacklogCard.jsx';
import CatalogGameCard from '../components/CatalogGameCard.jsx';
import Confetti from '../components/Confetti.jsx';
import GameAchievements from '../components/GameAchievements.jsx';
import GameHeroCard from '../components/GameHeroCard.jsx';
@@ -57,6 +58,12 @@ export default function GameDetail() {
}, [data, appid, navigate]);
const manual = !!data && data.source !== 'STEAM';
const errorBlock = error && (
<div className="py-12 text-center text-destructive space-y-4">
<p>{t(error)}</p>
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
</div>
);
return (
<PageContainer className="p-4 md:p-8 space-y-6">
@@ -71,12 +78,9 @@ export default function GameDetail() {
</div>
)}
{error && !isLoading && (
<div className="py-12 text-center text-destructive space-y-4">
<p>{t(error)}</p>
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
</div>
)}
{/* Not in the library: the catalog view, which falls back to the same message. */}
{error && !isLoading && (error === 'GAME_DETAIL.NOT_FOUND'
? <CatalogGameCard appid={appid} fallback={errorBlock} onAdded={reload}/> : errorBlock)}
{!error && data && (
<>
+58
View File
@@ -514,6 +514,64 @@ describe('GameDetail session', () => {
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
});
describe('a game that is not in the library', () => {
const preview = {appid: 501, name: 'Hades', imageUrl: null, genres: ['Roguelike'],
timeToBeat: {hastilyMinutes: 600, normallyMinutes: 1200, completelyMinutes: null}};
const notOwned = (previewAnswer) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games/501': json(404, {}),
'GET /api/library/games/501/preview': previewAnswer,
'GET /api/library/game-search': json(200, {results: []}),
});
it('showsTheCatalogDataAndOffersToAddTheGame', async () => {
notOwned(json(200, preview));
renderDetail(501);
expect(await screen.findByText('Hades')).toBeTruthy();
expect(screen.getByText('Roguelike')).toBeTruthy();
expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.NOT_FOUND')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'}));
expect(screen.getByRole('searchbox').value).toBe('Hades');
});
it('showsMyGamePageAfterAddingTheGameUnderTheSameKey', async () => {
let owned = false;
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'PS5'}}),
'GET /api/library/games/501': () => (owned ? json(200, {...game, appid: 501, name: 'Hades'}) : json(404, {})),
'GET /api/library/games/501/preview': json(200, preview),
'GET /api/library/game-search': json(200, {available: true, results: [
{appid: 501, igdbId: 7, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PS5'], inLibrary: false}]}),
'POST /api/library/games': () => {
owned = true;
return json(201, {appid: 501});
},
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: true, page: 0, totalPages: 0}),
// The points check gives up on a failed answer; the sonner mock of this file has no plain toast().
'GET /api/rewards/games/501': json(500, {}),
});
renderDetail(501);
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.ADD'}));
fireEvent.click(await screen.findByRole('button', {name: /Hades/}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
await waitFor(() => expect(screen.queryByText('CATALOG.NOT_IN_LIBRARY')).toBeNull());
});
it('showsTheNotFoundMessageWhenTheCatalogKnowsNoSuchGame', async () => {
notOwned(json(404, {}));
renderDetail(501);
expect(await screen.findByText('GAME_DETAIL.NOT_FOUND')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'CATALOG.ADD'})).toBeNull();
});
});
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
+41 -19
View File
@@ -7,6 +7,7 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js';
import {profileGameHref, profileHref} from '../utils/people.js';
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
@@ -43,8 +44,25 @@ export default function InboxPage() {
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
return t('INBOX.DIGEST', {name, parts});
return t('INBOX.DIGEST', {name, parts: partsText(note)});
};
const partsText = (note) => digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
// A link in a button is invalid, so the profile link sits next to the toggle button.
const noteLine = (note) => {
const linked = note.kind === 'DIGEST' && note.profileName;
const label = linked ? t('INBOX.DIGEST_PARTS', {parts: partsText(note)}) : text(note);
const line = note.kind === 'DIGEST' && note.details ? (
<button type="button" aria-expanded={open === note.id}
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
onClick={() => toggle(note.id)}>
{label}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : label;
if (!linked) return line;
return <><Link to={profileHref(note.profileName)} className="underline">{note.displayName}</Link>: {line}</>;
};
const toggle = async (id) => {
@@ -59,20 +77,31 @@ export default function InboxPage() {
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
};
const gameLines = (group, {gameName, items}, noteId) => {
const gameLabel = (note, {gameName, appid}) => (
<>
{appid == null ? gameName : <Link to={`/library/${appid}`} className="hover:underline">{gameName}</Link>}
{appid != null && note.profileName && (
<Link to={profileGameHref(note.profileName, appid)}
className="ml-2 text-xs text-muted-foreground underline">{t('INBOX.ON_PROFILE')}</Link>
)}
</>
);
const gameLines = (group, gameGroup, note) => {
const {gameName, items} = gameGroup;
if (group.kind === 'STATUS') {
return items.map((item, i) => (
<li key={`${gameName}-${i}`}>
{gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
{gameLabel(note, gameGroup)}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
</li>
));
}
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameName}</li>;
const key = `${noteId}:${gameName}`;
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameLabel(note, gameGroup)}</li>;
const key = `${note.id}:${gameName}`;
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
return (
<li key={gameName}>
{gameName}
{gameLabel(note, gameGroup)}
<ul className="ml-4 list-disc">
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
</ul>
@@ -86,8 +115,8 @@ export default function InboxPage() {
);
};
const detailBlock = (noteId) => {
const entry = details[noteId];
const detailBlock = (note) => {
const entry = details[note.id];
if (!entry || entry.status === 'loading') return <Loader2 className="size-4 animate-spin text-muted-foreground"/>;
if (entry.status === 'error') return <p className="text-xs text-destructive">{t('INBOX.DETAILS_ERROR')}</p>;
const groups = groupDetails(entry.results);
@@ -95,7 +124,7 @@ export default function InboxPage() {
return groups.map((group) => (
<div key={group.kind} className="space-y-1">
<h3 className="text-xs font-semibold uppercase text-muted-foreground">{t(`INBOX.DETAIL_${group.kind}`)}</h3>
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}</ul>
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, note))}</ul>
</div>
));
};
@@ -118,19 +147,12 @@ export default function InboxPage() {
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{note.kind === 'DIGEST' && note.details ? (
<button type="button" aria-expanded={open === note.id}
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
onClick={() => toggle(note.id)}>
{text(note)}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : text(note)}
{noteLine(note)}
{PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)}
</p>
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>}
</li>
))}
</ul>
+54
View File
@@ -68,6 +68,60 @@ describe('InboxPage', () => {
expect(putCalls()).toEqual([]);
});
describe('links', () => {
it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [
note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}),
note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})});
renderWithProviders(<InboxPage/>);
expect((await screen.findByRole('link', {name: 'Subject'})).getAttribute('href')).toBe('/u/Subject');
expect(screen.queryByRole('link', {name: 'Gone'})).toBeNull();
expect(screen.getByText('INBOX.DIGEST')).toBeTruthy();
});
it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [
note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})});
renderWithProviders(<InboxPage/>);
const link = await screen.findByRole('link', {name: 'Subject'});
expect(link.closest('button')).toBeNull();
expect(screen.getByRole('button', {name: 'INBOX.DIGEST_PARTS'})).toBeTruthy();
});
const open = async (rows, extra) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
'GET /api/inbox/1/details': json(200, {results: rows})});
renderWithProviders(<InboxPage/>);
fireEvent.click(await screen.findByRole('button'));
};
const game = (extra) => ({kind: 'NEW_GAME', gameName: 'Alpha', itemName: null, fromStatus: null, toStatus: null, ...extra});
it('linksTheGameToMyGamePageAndToTheProfileViewOfThatGame', async () => {
await open([game({appid: 501})], {profileName: 'Subject'});
expect((await screen.findByRole('link', {name: 'Alpha'})).getAttribute('href')).toBe('/library/501');
expect(screen.getByRole('link', {name: 'INBOX.ON_PROFILE'}).getAttribute('href')).toBe('/u/Subject?game=501');
});
it('givesNoProfileLinkWithoutAPublicProfile', async () => {
await open([game({appid: 501})], {profileName: null});
expect(await screen.findByRole('link', {name: 'Alpha'})).toBeTruthy();
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
});
it('leavesARowFromBeforeTheAppidAsPlainText', async () => {
await open([game({})], {profileName: 'Subject'});
expect(await screen.findByText('Alpha')).toBeTruthy();
expect(screen.queryByRole('link', {name: 'Alpha'})).toBeNull();
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
});
});
describe('details', () => {
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
+6 -1
View File
@@ -1,5 +1,5 @@
import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom';
import {Link, useParams, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
@@ -12,6 +12,7 @@ import FitText from '../components/FitText.jsx';
import FollowButton from '../components/people/FollowButton.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ProfileGameCard from '../components/ProfileGameCard.jsx';
import {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
@@ -41,6 +42,9 @@ const FINISHED_GAMES = {S: 6, M: 12};
export default function PublicProfilePage() {
const {t, i18n} = useTranslation();
const {name} = useParams();
const [params] = useSearchParams();
// Only a game key goes into the request address.
const game = /^-?\d+$/.test(params.get('game') ?? '') ? params.get('game') : null;
const [profile, setProfile] = useState(null);
const [library, setLibrary] = useState(null);
const [equipped, setEquipped] = useState({});
@@ -190,6 +194,7 @@ export default function PublicProfilePage() {
</Button>
)}
</Card>
{game && <ProfileGameCard ticket={profile.ticket} appid={game}/>}
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
+33
View File
@@ -103,6 +103,39 @@ describe('PublicProfilePage', () => {
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
});
describe('a game of the profile', () => {
const gameAnswer = {appid: 501, name: 'Hades', imageUrl: null, status: 'PLAYING', playtimeMinutes: 90,
achievementsUnlocked: 3, achievementsTotal: 10, completionPercent: 30};
const gameCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/library/games/'));
it('showsStatusPlaytimeAndAchievementsAboveTheTilesWithTheTicket', async () => {
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(200, gameAnswer)}});
renderPage('Lobo?game=501');
expect(await screen.findByText('Hades')).toBeTruthy();
const card = within(screen.getByText('Hades').closest('[data-slot="card"]'));
expect(card.getByText('PROFILE.GAME_ACHIEVEMENTS')).toBeTruthy();
expect(card.getByText('BACKLOG.STATUS.PLAYING')).toBeTruthy();
expect(gameCalls()[0][1].headers['X-Profile-Ticket']).toBe('the-ticket');
});
it('saysSoWhenTheProfileDoesNotShowTheGame', async () => {
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(404, {})}});
renderPage('Lobo?game=501');
expect(await screen.findByText('PROFILE.GAME_NOT_SHOWN')).toBeTruthy();
expect(screen.getByText('Portal 2')).toBeTruthy();
});
it('asksForNoGameWithoutTheParamOrWithAnythingButAKey', async () => {
mockBackends();
renderPage('Lobo?game=../x');
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(gameCalls()).toEqual([]);
});
});
it('asks Auth for the ticket, then Core for the library with it', async () => {
mockBackends({name: 'Lo bo'});
renderPage('Lo bo');