// @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(); 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(); }); });