Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

/node_modules
/build
.env
.env
/docs
106 changes: 106 additions & 0 deletions src/app/components/MediaCard.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { renderWithRouter, screen, fireEvent } from '../../test-utils';
import MediaCard from './MediaCard';

const baseMedia = {
ID: 42,
title: 'Attack on Titan',
year: '2020-04-07',
mediaType: 'anime',
tier: 'S',
};

describe('MediaCard', () => {
// ─── Rendering ─────────────────────────────────────────────────────────────

test('renders the media title', () => {
renderWithRouter(<MediaCard media={baseMedia} />);
expect(screen.getByText('Attack on Titan')).toBeInTheDocument();
});

test('renders the year extracted from the date string', () => {
renderWithRouter(<MediaCard media={baseMedia} />);
expect(screen.getByText('2020')).toBeInTheDocument();
});

test('renders "-" when year is null', () => {
renderWithRouter(<MediaCard media={{ ...baseMedia, year: null }} />);
expect(screen.getByText('-')).toBeInTheDocument();
});

test('renders "-" when year is undefined', () => {
renderWithRouter(<MediaCard media={{ ...baseMedia, year: undefined }} />);
expect(screen.getByText('-')).toBeInTheDocument();
});

// ─── Link vs onClick mode ───────────────────────────────────────────────────

test('renders a Link to the media details URL when no onCardClick prop', () => {
renderWithRouter(<MediaCard media={baseMedia} />);
const link = screen.getByRole('link', { name: /attack on titan/i });
expect(link).toBeInTheDocument();
expect(link.getAttribute('href')).toContain('/anime/42');
});

test('includes basePath in the link URL', () => {
renderWithRouter(<MediaCard media={baseMedia} basePath="/demo" />);
const link = screen.getByRole('link', { name: /attack on titan/i });
expect(link.getAttribute('href')).toContain('/demo/anime/42');
});

test('renders a span (not a Link) when onCardClick prop is provided', () => {
const onClick = jest.fn();
renderWithRouter(<MediaCard media={baseMedia} onCardClick={onClick} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Attack on Titan').tagName).toBe('SPAN');
});

test('calls onCardClick with the media object when clicked', () => {
const onClick = jest.fn();
renderWithRouter(<MediaCard media={baseMedia} onCardClick={onClick} />);
fireEvent.click(screen.getByText('Attack on Titan'));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(baseMedia);
});

test('does not call anything on click when no onCardClick', () => {
// Just verifying no crash when clicking the link
renderWithRouter(<MediaCard media={baseMedia} />);
expect(() => fireEvent.click(screen.getByRole('link'))).not.toThrow();
});

// ─── URL query string preservation ─────────────────────────────────────────

test('preserves existing query parameters in the link URL', () => {
renderWithRouter(<MediaCard media={baseMedia} />, {
route: '/anime/collection?tags=action,romance',
});
const link = screen.getByRole('link', { name: /attack on titan/i });
expect(link.getAttribute('href')).toContain('?tags=action,romance');
});

test('does not append query string when there are no params', () => {
renderWithRouter(<MediaCard media={baseMedia} />, { route: '/anime/collection' });
const link = screen.getByRole('link', { name: /attack on titan/i });
expect(link.getAttribute('href')).not.toContain('?');
});

// ─── Width style ────────────────────────────────────────────────────────────

test('sets a numeric pixel width style on the card element', () => {
const { container } = renderWithRouter(<MediaCard media={baseMedia} />);
const card = container.querySelector('.card');
expect(card.style.width).toMatch(/^\d+(\.\d+)?px$/);
});

test('applies pointer cursor when onCardClick is provided', () => {
const { container } = renderWithRouter(
<MediaCard media={baseMedia} onCardClick={jest.fn()} />
);
expect(container.querySelector('.card').style.cursor).toBe('pointer');
});

test('applies grab cursor when no onCardClick', () => {
const { container } = renderWithRouter(<MediaCard media={baseMedia} />);
expect(container.querySelector('.card').style.cursor).toBe('grab');
});
});
161 changes: 161 additions & 0 deletions src/app/components/TierTitle.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { MemoryRouter } from 'react-router-dom';
import { renderWithRouter, screen, fireEvent, waitFor } from '../../test-utils';
import axios from 'axios';
import TierTitle from './TierTitle';

jest.mock('axios');

const defaultProps = {
title: 'S Tier',
mediaType: 'anime',
group: 'collection',
tier: 'S',
setUserChanged: jest.fn(),
newType: false,
readOnly: false,
};

beforeEach(() => {
axios.defaults = {};
axios.put.mockResolvedValue({});
jest.clearAllMocks();
});

describe('TierTitle', () => {
// ─── Initial render ────────────────────────────────────────────────────────

test('renders the title text', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
expect(screen.getByText('S Tier')).toBeInTheDocument();
});

test('falls back to tier value when title prop is not provided', () => {
renderWithRouter(<TierTitle {...defaultProps} title={undefined} />);
expect(screen.getByText('S')).toBeInTheDocument();
});

test('shows the Add (+) button in non-readOnly mode', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
expect(screen.getByTitle('Add New')).toBeInTheDocument();
});

test('hides the Add (+) button in readOnly mode', () => {
renderWithRouter(<TierTitle {...defaultProps} readOnly={true} />);
expect(screen.queryByTitle('Add New')).not.toBeInTheDocument();
});

// ─── Edit mode activation ───────────────────────────────────────────────────

test('enters edit mode on double-click', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveValue('S Tier');
});

test('does not enter edit mode on double-click in readOnly mode', () => {
renderWithRouter(<TierTitle {...defaultProps} readOnly={true} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});

// ─── Edit mode interaction ─────────────────────────────────────────────────

test('updates the input value as the user types', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Supreme Tier' } });
expect(screen.getByRole('textbox')).toHaveValue('Supreme Tier');
});

test('shows the save (✓) button while editing', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
expect(screen.getByTitle('Save changes')).toBeInTheDocument();
});

// ─── Blur cancels ─────────────────────────────────────────────────────────

test('cancels editing on blur and reverts to original text', () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Changed Title' } });
fireEvent.blur(screen.getByRole('textbox'));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.getByText('S Tier')).toBeInTheDocument();
});

// ─── Save via onSave callback (demo mode) ──────────────────────────────────

test('calls onSave callback with the new text and exits edit mode', () => {
const onSave = jest.fn();
renderWithRouter(<TierTitle {...defaultProps} onSave={onSave} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Supreme Tier' } });
fireEvent.mouseDown(screen.getByTitle('Save changes'));
expect(onSave).toHaveBeenCalledWith('Supreme Tier');
// Edit mode exits; the displayed text reverts to the title prop since the parent
// hasn't updated it yet (onSave notifies the parent to do so)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});

test('does not call axios when onSave callback is provided', () => {
const onSave = jest.fn();
renderWithRouter(<TierTitle {...defaultProps} onSave={onSave} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.mouseDown(screen.getByTitle('Save changes'));
expect(axios.put).not.toHaveBeenCalled();
});

// ─── Save via API (app mode) ───────────────────────────────────────────────

test('calls axios.put with the correct URL and body when no onSave callback', async () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Supreme Tier' } });
fireEvent.mouseDown(screen.getByTitle('Save changes'));
expect(axios.put).toHaveBeenCalledWith(
expect.stringContaining('/api/user/anime/collection/S'),
expect.objectContaining({ newTitle: 'Supreme Tier' })
);
});

test('uses "todo" as groupKey when group is "to-do"', async () => {
renderWithRouter(<TierTitle {...defaultProps} group="to-do" />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.mouseDown(screen.getByTitle('Save changes'));
expect(axios.put).toHaveBeenCalledWith(
expect.stringContaining('/api/user/anime/todo/S'),
expect.any(Object)
);
});

test('exits edit mode after successful API save', async () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Supreme Tier' } });
fireEvent.mouseDown(screen.getByTitle('Save changes'));
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
});

test('calls setUserChanged(true) after a successful API save', async () => {
renderWithRouter(<TierTitle {...defaultProps} />);
fireEvent.doubleClick(screen.getByText('S Tier'));
fireEvent.mouseDown(screen.getByTitle('Save changes'));
await waitFor(() => expect(defaultProps.setUserChanged).toHaveBeenCalledWith(true));
});

// ─── Title prop update ─────────────────────────────────────────────────────

test('updates displayed text when the title prop changes', () => {
const { rerender } = renderWithRouter(<TierTitle {...defaultProps} title="S Tier" />);
expect(screen.getByText('S Tier')).toBeInTheDocument();
// rerender must include the MemoryRouter wrapper because TierTitle calls useNavigate()
rerender(
<MemoryRouter>
<TierTitle {...defaultProps} title="S+ Tier" />
</MemoryRouter>
);
expect(screen.getByText('S+ Tier')).toBeInTheDocument();
});
});