Files
ToBePlayed-Frontend/src/pages/QuestionsPage.test.jsx
T
lobo 84b16a2ca8
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 26s
feat(questions): list and restore removed games
Add a "Removed games" section on the questions page and an Undo
toast after removing a manual game (#39).
2026-10-11 09:52:11 +02:00

224 lines
9.6 KiB
React

// @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 {toast} from 'sonner';
import QuestionsPage from './QuestionsPage.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: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null});
const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED', appid, points: 30, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]});
const WALLET = {
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/rewards/expected': json(200, {}),
'GET /api/library/removed-games': json(200, {results: []}),
};
const expectedCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/expected'));
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
const renderPage = (routes) => {
const mock = fetchRoutes({...WALLET, ...routes});
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
return mock;
};
const bodyOf = (mock) => JSON.parse(mock.mock.calls.find(([, init]) => init?.method === 'POST')[1].body);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('QuestionsPage', () => {
it('showsTheRemovedGamesSectionAlsoWithoutQuestions', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: []}),
'GET /api/library/removed-games': json(200, {results: [{appid: -1, name: 'Zelda', imageUrl: null, source: 'MANUAL', platform: 'Switch'}]}),
});
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:1')).toBeTruthy();
});
it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})});
expect(await screen.findByText('Game 1')).toBeTruthy();
expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.getByText('QUESTIONS.START.ASK')).toBeTruthy();
expect(screen.getByText('QUESTIONS.TITLE_COUNT:2')).toBeTruthy();
});
it('removesTheCardAfterAYes', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.YES'})[0]);
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
expect(screen.getByText('Game 2')).toBeTruthy();
expect(bodyOf(mock)).toEqual({question: 'START', yes: true});
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
});
it('removesTheCardAfterANo', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'DROPPED')]}),
'POST /api/library/questions/1': json(200, {next: null}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'}));
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(bodyOf(mock)).toEqual({question: 'DROPPED', yes: false});
});
it('replacesTheCardWithTheRateQuestionAfterFinished', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('QUESTIONS.FINISHED.ASK');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
expect(await screen.findByText('QUESTIONS.RATE.ASK')).toBeTruthy();
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull();
});
it('showsThePointsToastAfterAYesToFinished', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'GET /api/rewards/games/1': json(200, booked(1)),
});
await screen.findByText('QUESTIONS.FINISHED.ASK');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
expect(pointsCalls()).toHaveLength(1);
});
it('asksForNoPointsAfterANoOrTheRatingAnswer', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'RATE')]}),
'POST /api/library/questions/1': json(200, {next: null}),
'POST /api/library/questions/2': json(200, {next: null}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.NO'})[0]);
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
await screen.findByText('QUESTIONS.EMPTY');
expect(pointsCalls()).toHaveLength(0);
});
it('sendsTheRatingOfTheStars', async () => {
const mock = renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
'POST /api/library/questions/1': json(200, {next: null}),
});
await screen.findByText('QUESTIONS.RATE.ASK');
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
await screen.findByText('QUESTIONS.EMPTY');
expect(bodyOf(mock)).toEqual({question: 'RATE', yes: true, rating: 5});
});
it('loadsTheListAgainWhenTheQuestionIsGone', async () => {
let results = [card(1, 'START')];
renderPage({
'GET /api/library/questions': () => json(200, {results}),
'POST /api/library/questions/1': () => {
results = [];
return json(409, {code: 'QUESTION_GONE'});
},
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(toast).toHaveBeenCalledWith('QUESTIONS.GONE');
});
it('keepsTheCardAndSaysSoWhenSavingFails', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'START')]}),
'POST /api/library/questions/1': json(500, {}),
});
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByText('Game 1')).toBeTruthy();
});
it('showsTheExpectedPointsOnAFinishedCardOnly', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED'), card(2, 'FINISHED'), card(3, 'START')]}),
'GET /api/rewards/expected': json(200, {1: 50, 2: 0}),
});
expect(await screen.findByText('QUESTIONS.FINISHED.POINTS:50')).toBeTruthy();
expect(screen.getAllByText(/^QUESTIONS.FINISHED.POINTS/)).toHaveLength(1);
expect(expectedCalls()[0][0]).toContain('appids=1,2');
});
it('asksForTheExpectedPointsOfEveryFinishedCardInChunks', async () => {
const cards = Array.from({length: 201}, (_, i) => card(i + 1, 'FINISHED'));
renderPage({'GET /api/library/questions': json(200, {results: cards})});
await waitFor(() => expect(expectedCalls().length).toBeGreaterThan(0));
const asked = expectedCalls().flatMap(([url]) => new URL(url, 'http://x').searchParams.get('appids').split(','));
expect(asked).toContain('201');
});
it('asksForNoExpectedPointsWithoutAFinishedCard', async () => {
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START')]})});
await screen.findByText('Game 1');
expect(expectedCalls()).toHaveLength(0);
});
it('showsNoPointsLineAndNoErrorWhenTheExpectedCallFails', async () => {
renderPage({
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
'GET /api/rewards/expected': json(500, {}),
});
expect(await screen.findByText('Game 1')).toBeTruthy();
await waitFor(() => expect(expectedCalls()).toHaveLength(1));
expect(screen.queryByText(/^QUESTIONS.FINISHED.POINTS/)).toBeNull();
expect(screen.queryByText('INFO.ERROR.NETWORK_FAILURE')).toBeNull();
});
it('showsTheEmptyState', async () => {
renderPage({'GET /api/library/questions': json(200, {results: []})});
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
});
});