Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_CACH_TTL=120
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-class-components",
"homepage": "https://shushanik01.github.io/react-class-components",
"homepage": "https://shushanik01.github.io/react-class-components_RSschool",
"private": true,
"version": "0.0.0",
"type": "module",
Expand All @@ -20,6 +20,7 @@
},
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"dotenv": "^17.4.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-redux": "^9.3.0",
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/CardItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import CardItem from '../components/CardItem/CardItem';

const onCardClick = vi.fn();

const onToggleSelect = vi.fn();

const defaultProps = {
id: 1,
name: 'bulbasaur',
type: 'grass',
weight: 69,
ability: 'overgrow',
image: 'https://example.com/bulbasaur.png',
isSelected: false,
onCardClick,
onToggleSelect,
};

describe('CardItem', () => {
Expand Down
79 changes: 70 additions & 9 deletions src/__tests__/CardList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,77 @@ const noop = () => {};

describe('CardList', () => {
it('renders the correct number of items', () => {
render(<CardList items={mockItems} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={mockItems}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
const items = screen.getAllByRole('article');
expect(items).toHaveLength(mockItems.length);
});

it('displays each pokemon name', () => {
render(<CardList items={mockItems} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={mockItems}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText(/bulbasaur/i)).toBeInTheDocument();
expect(screen.getByText(/charmander/i)).toBeInTheDocument();
});

it('shows "No results found" when items array is empty', () => {
render(<CardList items={[]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={[]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText('No results found')).toBeInTheDocument();
});

it('renders without crashing when items is an empty array', () => {
const { container } = render(<CardList items={[]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
const { container } = render(
<CardList
items={[]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(container).toBeInTheDocument();
});

it('renders a single item correctly', () => {
render(<CardList items={[mockItems[0]]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={[mockItems[0]]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getAllByRole('article')).toHaveLength(1);
expect(screen.getByText(/bulbasaur/i)).toBeInTheDocument();
});

it('displays type for each item', () => {
render(<CardList items={mockItems} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={mockItems}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText(/grass/i)).toBeInTheDocument();
expect(screen.getByText(/fire/i)).toBeInTheDocument();
});
Expand All @@ -49,18 +91,37 @@ describe('CardList', () => {
abilities: [{ ability: { name: 'run-away' } }],
sprites: { front_default: '' },
};
render(<CardList items={[itemWithMinimalData]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={[itemWithMinimalData]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText(/testmon/i)).toBeInTheDocument();
});

it('shows "No results found" when items is null', () => {
render(<CardList items={null as unknown as Item[]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />);
render(
<CardList
items={null as unknown as Item[]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText('No results found')).toBeInTheDocument();
});

it('shows "No results found" when items is undefined', () => {
render(
<CardList items={undefined as unknown as Item[]} onCardClick={noop} selectedIds={[]} onToggleSelect={noop} />
<CardList
items={undefined as unknown as Item[]}
onCardClick={noop}
selectedIds={[]}
onToggleSelect={noop}
/>
);
expect(screen.getByText('No results found')).toBeInTheDocument();
});
Expand Down
105 changes: 71 additions & 34 deletions src/__tests__/DetailsPannel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { render, screen, act } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import DetailsPannel from '../components/DetailsPannel/DetailsPannel';
import pokemonListReducer from '../slices/pokemonListSlice';
import pokemonDetailsReducer from '../slices/pokemonDetailsSlice';
import * as api from '../services/api';
import selectedItemsReducer from '../slices/selectedItemsSlice';
import { useGetSinglePokemonQuery } from '../api/api';
import { mockItem } from './mocks/mockData';
import type { Item } from '../types';

vi.mock('../services/api');
vi.mock('../api/api', () => ({
useGetSinglePokemonQuery: vi.fn(),
pokemonApi: {},
}));

const mockNavigate = vi.hoisted(() => vi.fn());
const mockUseParams = vi.hoisted(() => vi.fn(() => ({ id: '1' })));
const mockUseParams = vi.hoisted(() =>
vi.fn(() => ({ id: '1' as string | undefined }))
);

vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
Expand All @@ -23,6 +28,21 @@ vi.mock('react-router', async (importOriginal) => {
};
});

type SingleQueryResult = ReturnType<typeof useGetSinglePokemonQuery>;

const mockQuery = (overrides: object) =>
overrides as unknown as SingleQueryResult;

const defaultQueryResult = {
data: undefined,
isLoading: false,
isFetching: false,
isSuccess: false,
isError: false,
isUninitialized: false,
error: undefined,
};

const mockItemWithStats: Item = {
...mockItem,
height: 7,
Expand All @@ -36,7 +56,7 @@ const createTestStore = () =>
configureStore({
reducer: {
pokemonList: pokemonListReducer,
pokemonDetails: pokemonDetailsReducer,
selectedItems: selectedItemsReducer,
},
});

Expand All @@ -47,14 +67,11 @@ const renderWithStore = (ui: React.ReactElement) => {

describe('DetailsPannel', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockUseParams.mockReturnValue({ id: '1' });
vi.mocked(api.getData).mockResolvedValue(mockItem);
});

afterEach(() => {
vi.useRealTimers();
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({ ...defaultQueryResult, data: mockItem, isSuccess: true })
);
});

it('renders the Pokémon Details header', () => {
Expand All @@ -67,49 +84,69 @@ describe('DetailsPannel', () => {
expect(screen.getByRole('button', { name: '✕' })).toBeInTheDocument();
});

it('shows pokemon details after fetch completes', async () => {
it('shows loading spinner while the query is in flight', () => {
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({ ...defaultQueryResult, isLoading: true })
);
renderWithStore(<DetailsPannel />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('shows pokemon name and data after the query succeeds', () => {
renderWithStore(<DetailsPannel />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('bulbasaur')).toBeInTheDocument();
expect(api.getData).toHaveBeenCalledWith('1');
});

it('shows error message when fetch fails', async () => {
vi.mocked(api.getData).mockRejectedValue(
new Error('Pokemon not found. Please check the name')
it('shows error message when the query fails with a message', () => {
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({
...defaultQueryResult,
isError: true,
error: { message: 'Pokemon not found. Please check the name' },
})
);
renderWithStore(<DetailsPannel />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText(/pokemon not found/i)).toBeInTheDocument();
});

it('navigates to "/" when close button is clicked', () => {
it('navigates to "/" when the close button is clicked', () => {
renderWithStore(<DetailsPannel />);
fireEvent.click(screen.getByRole('button', { name: '✕' }));
expect(mockNavigate).toHaveBeenCalledWith('/');
});

it('does not fetch when id is undefined', async () => {
it('passes skip: true to the query when id is undefined', () => {
mockUseParams.mockReturnValue({ id: undefined });
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({ ...defaultQueryResult, isUninitialized: true })
);
renderWithStore(<DetailsPannel />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(api.getData).not.toHaveBeenCalled();
expect(useGetSinglePokemonQuery).toHaveBeenCalledWith('', { skip: true });
});

it('renders stats and height when details include them', async () => {
vi.mocked(api.getData).mockResolvedValue(mockItemWithStats);
it('renders stats and height when the data includes them', () => {
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({
...defaultQueryResult,
data: mockItemWithStats,
isSuccess: true,
})
);
renderWithStore(<DetailsPannel />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('hp:')).toBeInTheDocument();
expect(screen.getByText('attack:')).toBeInTheDocument();
expect(screen.getByText('0.7 m')).toBeInTheDocument();
});

it('shows fallback error text when error has no message property', () => {
vi.mocked(useGetSinglePokemonQuery).mockReturnValue(
mockQuery({
...defaultQueryResult,
isError: true,
error: { status: 503 },
})
);
renderWithStore(<DetailsPannel />);
expect(screen.getByText(/Failed to load pokemon/i)).toBeInTheDocument();
});
});
Loading