Public Access
feat(audit): game catalog search and time to beat correction
New page /audit/games: search every game and open the correction dialog without owning it. Time to beat as hours + minutes. Audit log game links open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
1 parent
6627a8db66
commit
0c300c455d
14 files changed
+439
-19
No files matched your search
@@ -129,11 +129,14 @@ To Be Played is split into three repositories:
|
|||||||
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
||||||
links to it again.
|
links to it again.
|
||||||
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||||||
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
|
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
|
||||||
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
||||||
every user.
|
every user.
|
||||||
|
- **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms,
|
||||||
|
key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links
|
||||||
|
its games here.
|
||||||
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
|
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
|
||||||
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks and dismissals with who
|
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks, dismissals, corrections and resets with who
|
||||||
did it, filter by action, paged.
|
did it, filter by action, paged.
|
||||||
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
||||||
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
|
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
|
||||||
|
|||||||
+20
-3
@@ -349,7 +349,8 @@
|
|||||||
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
||||||
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
||||||
"LINK_QUEUE": "Verknüpfungen",
|
"LINK_QUEUE": "Verknüpfungen",
|
||||||
"AUDIT_LOG": "Protokoll"
|
"AUDIT_LOG": "Protokoll",
|
||||||
|
"GAME_CATALOG": "Spielekatalog"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -626,6 +627,9 @@
|
|||||||
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
||||||
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
||||||
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
||||||
|
"HOURS": "Stunden",
|
||||||
|
"MINUTES": "Minuten",
|
||||||
|
"NEGATIVE": "Nicht gespeichert: Stunden und Minuten dürfen nicht negativ sein.",
|
||||||
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
|
||||||
"FIELD": {
|
"FIELD": {
|
||||||
"NAME": "Name",
|
"NAME": "Name",
|
||||||
@@ -633,7 +637,8 @@
|
|||||||
"SPEEDRUN": "Speedrun-Link",
|
"SPEEDRUN": "Speedrun-Link",
|
||||||
"MOODS": "Stimmungen",
|
"MOODS": "Stimmungen",
|
||||||
"ENDLESS": "Endloses Spiel",
|
"ENDLESS": "Endloses Spiel",
|
||||||
"GENRES": "Genres"
|
"GENRES": "Genres",
|
||||||
|
"TIME_TO_BEAT": "Spielzeit"
|
||||||
},
|
},
|
||||||
"ENDLESS": {
|
"ENDLESS": {
|
||||||
"UNKNOWN": "Unbekannt",
|
"UNKNOWN": "Unbekannt",
|
||||||
@@ -689,6 +694,16 @@
|
|||||||
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CATALOG": {
|
||||||
|
"TITLE": "Spielekatalog",
|
||||||
|
"SEARCH": "Nach Name suchen",
|
||||||
|
"EMPTY": "Keine Spiele gefunden.",
|
||||||
|
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
|
||||||
|
"LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.",
|
||||||
|
"CORRECT": "Korrigieren",
|
||||||
|
"KEY": "Schlüssel {{appid}}",
|
||||||
|
"GONE": "Dieses Spiel gibt es nicht mehr."
|
||||||
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Protokoll",
|
"TITLE": "Protokoll",
|
||||||
"EMPTY": "Keine Einträge.",
|
"EMPTY": "Keine Einträge.",
|
||||||
@@ -702,7 +717,9 @@
|
|||||||
"GAME_MERGE": "Zusammengeführt",
|
"GAME_MERGE": "Zusammengeführt",
|
||||||
"GAME_LINK": "Verknüpft",
|
"GAME_LINK": "Verknüpft",
|
||||||
"GAME_UNLINK": "Verknüpfung gelöst",
|
"GAME_UNLINK": "Verknüpfung gelöst",
|
||||||
"GAME_LINK_DISMISS": "Verworfen"
|
"GAME_LINK_DISMISS": "Verworfen",
|
||||||
|
"GAME_CORRECT": "Korrigiert",
|
||||||
|
"GAME_CORRECTION_RESET": "Zurück zur Quelle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"HINT": {
|
"HINT": {
|
||||||
|
|||||||
+20
-3
@@ -349,7 +349,8 @@
|
|||||||
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
||||||
"PENDING_REQUESTS_other": "{{count}} registrations wait",
|
"PENDING_REQUESTS_other": "{{count}} registrations wait",
|
||||||
"LINK_QUEUE": "Link queue",
|
"LINK_QUEUE": "Link queue",
|
||||||
"AUDIT_LOG": "Audit log"
|
"AUDIT_LOG": "Audit log",
|
||||||
|
"GAME_CATALOG": "Game catalog"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -626,6 +627,9 @@
|
|||||||
"NO_ACCESS": "Only auditors can correct game data.",
|
"NO_ACCESS": "Only auditors can correct game data.",
|
||||||
"ERROR": "The correction could not be saved.",
|
"ERROR": "The correction could not be saved.",
|
||||||
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
||||||
|
"HOURS": "hours",
|
||||||
|
"MINUTES": "minutes",
|
||||||
|
"NEGATIVE": "Not saved: hours and minutes cannot be negative.",
|
||||||
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
|
||||||
"FIELD": {
|
"FIELD": {
|
||||||
"NAME": "Name",
|
"NAME": "Name",
|
||||||
@@ -633,7 +637,8 @@
|
|||||||
"SPEEDRUN": "Speedrun link",
|
"SPEEDRUN": "Speedrun link",
|
||||||
"MOODS": "Moods",
|
"MOODS": "Moods",
|
||||||
"ENDLESS": "Endless game",
|
"ENDLESS": "Endless game",
|
||||||
"GENRES": "Genres"
|
"GENRES": "Genres",
|
||||||
|
"TIME_TO_BEAT": "Time to beat"
|
||||||
},
|
},
|
||||||
"ENDLESS": {
|
"ENDLESS": {
|
||||||
"UNKNOWN": "Unknown",
|
"UNKNOWN": "Unknown",
|
||||||
@@ -689,6 +694,16 @@
|
|||||||
"GAME_NOT_FOUND": "This game is not in your library."
|
"GAME_NOT_FOUND": "This game is not in your library."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CATALOG": {
|
||||||
|
"TITLE": "Game catalog",
|
||||||
|
"SEARCH": "Search by name",
|
||||||
|
"EMPTY": "No games found.",
|
||||||
|
"NO_ACCESS": "Only auditors can open this page.",
|
||||||
|
"LOAD_ERROR": "The game catalog could not be loaded.",
|
||||||
|
"CORRECT": "Correct",
|
||||||
|
"KEY": "Key {{appid}}",
|
||||||
|
"GONE": "This game no longer exists."
|
||||||
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Audit log",
|
"TITLE": "Audit log",
|
||||||
"EMPTY": "No entries.",
|
"EMPTY": "No entries.",
|
||||||
@@ -702,7 +717,9 @@
|
|||||||
"GAME_MERGE": "Merge",
|
"GAME_MERGE": "Merge",
|
||||||
"GAME_LINK": "Link",
|
"GAME_LINK": "Link",
|
||||||
"GAME_UNLINK": "Unlink",
|
"GAME_UNLINK": "Unlink",
|
||||||
"GAME_LINK_DISMISS": "Dismissed"
|
"GAME_LINK_DISMISS": "Dismissed",
|
||||||
|
"GAME_CORRECT": "Corrected",
|
||||||
|
"GAME_CORRECTION_RESET": "Back to source"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"HINT": {
|
"HINT": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import WelcomePage from './pages/WelcomePage.jsx';
|
|||||||
import WelcomeGate from './components/WelcomeGate.jsx';
|
import WelcomeGate from './components/WelcomeGate.jsx';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage.jsx';
|
import AdminUsersPage from './pages/AdminUsersPage.jsx';
|
||||||
import AuditLogPage from './pages/AuditLogPage.jsx';
|
import AuditLogPage from './pages/AuditLogPage.jsx';
|
||||||
|
import GameCatalogPage from './pages/GameCatalogPage.jsx';
|
||||||
import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
@@ -89,6 +90,7 @@ function App() {
|
|||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
||||||
<Route path="/welcome" element={<WelcomePage/>}/>
|
<Route path="/welcome" element={<WelcomePage/>}/>
|
||||||
|
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
||||||
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
||||||
<Route path="/audit/log" element={<AuditLogPage/>}/>
|
<Route path="/audit/log" element={<AuditLogPage/>}/>
|
||||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
|
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canCorrect(currentUser) && (
|
{canCorrect(currentUser) && (
|
||||||
<>
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
||||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||||
openMenu();
|
openMenu();
|
||||||
|
|
||||||
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
|
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
|
||||||
|
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
|
||||||
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
||||||
|
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
|
||||||
|
|
||||||
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
||||||
|
|
||||||
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
||||||
|
|
||||||
|
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
|
||||||
|
|
||||||
|
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
|
||||||
|
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
|
||||||
|
|
||||||
|
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
|
||||||
|
|
||||||
const initialValues = (game) => ({
|
const initialValues = (game) => ({
|
||||||
name: game.name ?? '',
|
name: game.name ?? '',
|
||||||
image: game.imageUrl ?? '',
|
image: game.imageUrl ?? '',
|
||||||
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
|
|||||||
moods: game.moods ?? [],
|
moods: game.moods ?? [],
|
||||||
endless: game.endless ?? null,
|
endless: game.endless ?? null,
|
||||||
genres: game.genres ?? [],
|
genres: game.genres ?? [],
|
||||||
|
time_to_beat: {
|
||||||
|
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
|
||||||
|
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// An empty text field means "none": the Steam header, or no speedrun.
|
// An empty text field means "none": the Steam header, or no speedrun.
|
||||||
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
|
const toValue = (field, value) => {
|
||||||
|
if (field === 'time_to_beat') {
|
||||||
|
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value.trim() || null : value;
|
||||||
|
};
|
||||||
|
|
||||||
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
||||||
|
|
||||||
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const save = (field) => {
|
||||||
|
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
|
||||||
|
toast.error(t('CORRECTION.NEGATIVE'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
|
||||||
|
};
|
||||||
|
|
||||||
const row = (field, input) => (
|
const row = (field, input) => (
|
||||||
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
|
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
{input}
|
{input}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" size="sm" disabled={busy !== null}
|
<Button type="button" size="sm" disabled={busy !== null}
|
||||||
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
|
onClick={() => save(field)}>
|
||||||
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
||||||
</Button>
|
</Button>
|
||||||
{corrected.has(field) && (
|
{corrected.has(field) && (
|
||||||
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timeInputs = (kind, label) => {
|
||||||
|
const setPart = (part, text) => set('time_to_beat', {
|
||||||
|
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-20 text-sm">{label}</span>
|
||||||
|
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
|
||||||
|
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
|
||||||
|
onChange={(e) => setPart('h', e.target.value)}/>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
|
||||||
|
<Input type="number" min="0" max="59" step="1" className="w-20"
|
||||||
|
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
|
||||||
|
onChange={(e) => setPart('m', e.target.value)}/>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// A genre IGDB added later still shows, so it can be removed.
|
// A genre IGDB added later still shows, so it can be removed.
|
||||||
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
|
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
|
||||||
|
|
||||||
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
))}
|
))}
|
||||||
{row('genres', choices('genres', genreOptions,
|
{row('genres', choices('genres', genreOptions,
|
||||||
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
|
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
|
||||||
|
{row('time_to_beat', (
|
||||||
|
<div id="correction-time_to_beat" className="space-y-2">
|
||||||
|
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
|
||||||
|
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ const game = {
|
|||||||
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
|
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
|
||||||
moods: ['STORY'], endless: null, genres: ['Puzzle'],
|
moods: ['STORY'], endless: null, genres: ['Puzzle'],
|
||||||
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
||||||
|
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAME_URL = '/api/library/games/620/corrections';
|
const GAME_URL = '/api/library/games/620/corrections';
|
||||||
const corrections = (answer) => fetchRoutes({
|
const corrections = (answer) => fetchRoutes({
|
||||||
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
||||||
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderDialog = (onChanged = vi.fn()) => {
|
const renderDialog = (onChanged = vi.fn()) => {
|
||||||
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
|
|||||||
return onChanged;
|
return onChanged;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The rows in order: name, image, speedrun, moods, endless, genres.
|
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
|
||||||
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
|
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
|
||||||
|
|
||||||
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
|
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
|
||||||
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
|
|||||||
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
|
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendsFractionalHoursAsExactMinutes', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(sent()[0].body.value.normallyMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refusesANegativeTimePartBeforeSaving', () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers back to source for a corrected time to beat', () => {
|
||||||
|
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
|
||||||
|
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
|
||||||
|
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('marks a corrected field and goes back to the source', async () => {
|
it('marks a corrected field and goes back to the source', async () => {
|
||||||
const onChanged = renderDialog();
|
const onChanged = renderDialog();
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import {fetchAuditLog} from '../utils/links.js';
|
|||||||
import {formatDateTime} from '../utils/format.js';
|
import {formatDateTime} from '../utils/format.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS'];
|
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
|
||||||
|
'GAME_CORRECTION_RESET'];
|
||||||
const ALL = 'ALL';
|
const ALL = 'ALL';
|
||||||
|
|
||||||
function GameRef({appid, name, fallback}) {
|
function GameRef({appid, name, fallback}) {
|
||||||
if (appid == null) return null;
|
if (appid == null) return null;
|
||||||
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe('AuditLogPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('lobo')).toBeTruthy();
|
expect(await screen.findByText('lobo')).toBeTruthy();
|
||||||
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
|
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
|
||||||
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/library/10');
|
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/audit/games?appid=10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
|
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {useSearchParams} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||||
|
import GameCover from '../components/GameCover.jsx';
|
||||||
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
|
||||||
|
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
|
||||||
|
* directly (the audit log links here). Core checks the role.
|
||||||
|
*/
|
||||||
|
export default function GameCatalogPage() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const openAppid = params.get('appid');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [reloads, setReloads] = useState(0);
|
||||||
|
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
|
||||||
|
const openRef = useRef(openAppid);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = openAppid;
|
||||||
|
}, [openAppid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (query.trim() !== term) {
|
||||||
|
setTerm(query.trim());
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, term]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (response.status === 403) {
|
||||||
|
setStatus('forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(await response.json());
|
||||||
|
setStatus('ready');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
console.error('Catalog error:', err);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [term, page, reloads]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setDetail(null);
|
||||||
|
setParams({}, {replace: true});
|
||||||
|
}, [setParams]);
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async (appid) => {
|
||||||
|
try {
|
||||||
|
const response = await fetchCatalogGame(appid);
|
||||||
|
if (response.status === 401 || String(appid) !== openRef.current) return;
|
||||||
|
if (response.ok) {
|
||||||
|
setDetail(await response.json());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 404: the game was merged meanwhile.
|
||||||
|
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
|
||||||
|
close();
|
||||||
|
setReloads((n) => n + 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Catalog game error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [close, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openAppid) loadDetail(openAppid);
|
||||||
|
else setDetail(null);
|
||||||
|
}, [openAppid, loadDetail]);
|
||||||
|
|
||||||
|
const changed = () => {
|
||||||
|
loadDetail(detail.appid);
|
||||||
|
setReloads((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === 'forbidden') {
|
||||||
|
return (
|
||||||
|
<PageContainer className="px-4 py-6 md:px-8">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-3">
|
||||||
|
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||||
|
<Input type="search" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
|
||||||
|
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||||
|
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
|
||||||
|
{status === 'ready' && result.content.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
|
||||||
|
)}
|
||||||
|
{status === 'ready' && result.content.length > 0 && (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{result.content.map((game) => (
|
||||||
|
<li key={game.appid} className="flex items-center gap-3 py-2">
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{game.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="outline"
|
||||||
|
onClick={() => setParams({appid: String(game.appid)})}>
|
||||||
|
{t('CATALOG.CORRECT')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{status === 'ready' && result.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||||
|
onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||||
|
</Button>
|
||||||
|
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}>
|
||||||
|
{t('ADMIN.NEXT')}<ChevronRight/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{detail && (
|
||||||
|
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import GameCatalogPage from './GameCatalogPage.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
|
||||||
|
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
|
||||||
|
timeToBeat: null, corrections: []};
|
||||||
|
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||||
|
|
||||||
|
const renderPage = (routes, url = '/audit/games') => {
|
||||||
|
fetchRoutes(routes);
|
||||||
|
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const urls = () => fetch.mock.calls.map(([url]) => url);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GameCatalogPage', () => {
|
||||||
|
it('listsHitsWithPlatformsAndKey', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchesWithTheTypedName', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||||
|
await screen.findByText('Hades');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheCorrectionDialogForTheChosenGame', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||||
|
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheGameOfTheAppidInTheUrl', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
|
||||||
|
'/audit/games?appid=10');
|
||||||
|
|
||||||
|
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saysSoWhenTheGameIsGone', async () => {
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
|
||||||
|
'/audit/games?appid=10');
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
|
||||||
|
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsNoAccessOn403', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': json(403, {})});
|
||||||
|
|
||||||
|
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagesThroughTheCatalog', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
|
||||||
|
await screen.findByText('Hades');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,3 +41,18 @@ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {me
|
|||||||
export const mergeGame = (appid, otherAppid) =>
|
export const mergeGame = (appid, otherAppid) =>
|
||||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
|
||||||
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
|
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: 200 `{content: [{appid, name, imageUrl, platforms}], totalPages}` of every game some user has, 403 below
|
||||||
|
* AUDITOR. A blank q means all.
|
||||||
|
*/
|
||||||
|
export const searchCatalog = ({q, page, size}, signal) => {
|
||||||
|
const params = new URLSearchParams({page: String(page), size: String(size)});
|
||||||
|
if (q?.trim()) params.set('q', q.trim());
|
||||||
|
return apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog?${params}`, {signal});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: 200 what GameCorrectionDialog reads (like the game details), 403 below AUDITOR, 404 GAME_NOT_FOUND.
|
||||||
|
*/
|
||||||
|
export const fetchCatalogGame = (appid) => apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog/${appid}`);
|
||||||
@@ -23,6 +23,8 @@ export const MAX_GAME_SEARCH_LENGTH = 100;
|
|||||||
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
|
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
|
||||||
// Core GameCorrectionService.MAX_TEXT
|
// Core GameCorrectionService.MAX_TEXT
|
||||||
export const MAX_CORRECTION_LENGTH = 512;
|
export const MAX_CORRECTION_LENGTH = 512;
|
||||||
|
// Core GameCorrectionService.MAX_TIME_TO_BEAT_MINUTES
|
||||||
|
export const MAX_TIME_TO_BEAT_MINUTES = 60_000;
|
||||||
// Core GameCorrectionService.MAX_GENRES
|
// Core GameCorrectionService.MAX_GENRES
|
||||||
export const MAX_CORRECTION_GENRES = 10;
|
export const MAX_CORRECTION_GENRES = 10;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user