Public Access
test: component tests with jsdom and Testing Library
First tests cover the library search debounce (fails on the old looping version), the Pile of Shame states and the backlog save body. jsdom is enabled per test file, so the plain util tests stay in the node environment.
This commit is contained in:
1 parent
c24c22d5ed
commit
bad11807e0
6 files changed
+918
No files matched your search
@@ -0,0 +1,48 @@
|
||||
// @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 BacklogCard from './BacklogCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
|
||||
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||
|
||||
describe('BacklogCard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('enables saving only after a change', () => {
|
||||
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
||||
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the rating on a click on the current star and sends blank notes as null', async () => {
|
||||
const onSaved = vi.fn();
|
||||
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
|
||||
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
||||
fireEvent.click(saveButton());
|
||||
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: null, notes: null, priority: null});
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import PileOfShameCard from './PileOfShameCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const summary = (backlog, extra = {}) => ({
|
||||
total: backlog + 3,
|
||||
neverPlayed: 1,
|
||||
byStatus: {BACKLOG: backlog, PLAYING: 3, FINISHED: 0, COMPLETED: 0, DROPPED: 0},
|
||||
suggestion: backlog > 0 ? {appid: 220, name: 'Half-Life 2'} : null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const renderCard = (props) => render(<MemoryRouter><PileOfShameCard onShuffle={() => {}} {...props}/></MemoryRouter>);
|
||||
|
||||
describe('PileOfShameCard', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows the first-sync text instead of empty numbers while the first sync runs', () => {
|
||||
renderCard({summary: {...summary(0), total: 0, byStatus: {BACKLOG: 0}}, syncing: true});
|
||||
|
||||
expect(screen.getByText('LIBRARY.SYNCING_FIRST_TIME')).toBeTruthy();
|
||||
expect(screen.queryByText('DASHBOARD.BACKLOG_EMPTY')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the shuffle button when there is nothing else to pick', () => {
|
||||
renderCard({summary: summary(1)});
|
||||
|
||||
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: /DASHBOARD.OTHER_SUGGESTION/})).toBeNull();
|
||||
});
|
||||
|
||||
it('shuffles when the backlog has more than one game', () => {
|
||||
const onShuffle = vi.fn();
|
||||
renderCard({summary: summary(2), onShuffle});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: /DASHBOARD.OTHER_SUGGESTION/}));
|
||||
|
||||
expect(onShuffle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('says the backlog is empty when there is no suggestion', () => {
|
||||
renderCard({summary: summary(0)});
|
||||
|
||||
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||
|
||||
const DEBOUNCE_WAIT_MS = 700;
|
||||
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
|
||||
|
||||
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
// MemoryRouter, not createMemoryRouter: it pushes like the BrowserRouter in main.jsx, also for an unchanged URL.
|
||||
function renderLibrary(initialEntry = '/library') {
|
||||
const lib = {navigations: 0, location: null, navigate: null};
|
||||
function Spy() {
|
||||
const location = useLocation();
|
||||
lib.navigate = useNavigate();
|
||||
if (lib.location?.key !== location.key) {
|
||||
lib.navigations++;
|
||||
lib.location = location;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
return {
|
||||
navigations: () => lib.navigations,
|
||||
params: () => new URLSearchParams(lib.location.search),
|
||||
back: () => lib.navigate(-1),
|
||||
};
|
||||
}
|
||||
|
||||
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
|
||||
|
||||
describe('GameLibrary search', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
||||
const lib = renderLibrary('/library?page=3');
|
||||
|
||||
typeInto('half');
|
||||
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
expect(lib.params().get('page')).toBe('0');
|
||||
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]q=half(&|$)/));
|
||||
});
|
||||
|
||||
it('does not keep navigating and keeps a trailing space while the user types', async () => {
|
||||
const lib = renderLibrary();
|
||||
typeInto('half');
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
const navigationsBefore = lib.navigations();
|
||||
|
||||
typeInto('half ');
|
||||
await wait(DEBOUNCE_WAIT_MS);
|
||||
|
||||
expect(lib.navigations()).toBe(navigationsBefore);
|
||||
expect(screen.getByRole('searchbox').value).toBe('half ');
|
||||
});
|
||||
|
||||
it('keeps a status picked during the delay', async () => {
|
||||
const lib = renderLibrary();
|
||||
|
||||
typeInto('half');
|
||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'}));
|
||||
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
expect(lib.params().get('status')).toBe('DROPPED');
|
||||
});
|
||||
|
||||
it('shows the URL search again after back navigation', async () => {
|
||||
const lib = renderLibrary();
|
||||
typeInto('half');
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
|
||||
act(() => lib.back());
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts.
|
||||
// Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
const t = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`);
|
||||
const i18n = {language: 'de'};
|
||||
|
||||
export const useTranslation = () => ({t, i18n});
|
||||
Reference in new issue
Block a user