diff --git a/.gitignore b/.gitignore index 129de6d..3d0340b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /node_modules /build -.env \ No newline at end of file +.env +/docs \ No newline at end of file diff --git a/src/app/components/MediaCard.test.jsx b/src/app/components/MediaCard.test.jsx new file mode 100644 index 0000000..84c79f9 --- /dev/null +++ b/src/app/components/MediaCard.test.jsx @@ -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(); + expect(screen.getByText('Attack on Titan')).toBeInTheDocument(); + }); + + test('renders the year extracted from the date string', () => { + renderWithRouter(); + expect(screen.getByText('2020')).toBeInTheDocument(); + }); + + test('renders "-" when year is null', () => { + renderWithRouter(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + test('renders "-" when year is undefined', () => { + renderWithRouter(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + // ─── Link vs onClick mode ─────────────────────────────────────────────────── + + test('renders a Link to the media details URL when no onCardClick prop', () => { + renderWithRouter(); + 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(); + 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(); + 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(); + 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(); + expect(() => fireEvent.click(screen.getByRole('link'))).not.toThrow(); + }); + + // ─── URL query string preservation ───────────────────────────────────────── + + test('preserves existing query parameters in the link URL', () => { + renderWithRouter(, { + 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(, { 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(); + const card = container.querySelector('.card'); + expect(card.style.width).toMatch(/^\d+(\.\d+)?px$/); + }); + + test('applies pointer cursor when onCardClick is provided', () => { + const { container } = renderWithRouter( + + ); + expect(container.querySelector('.card').style.cursor).toBe('pointer'); + }); + + test('applies grab cursor when no onCardClick', () => { + const { container } = renderWithRouter(); + expect(container.querySelector('.card').style.cursor).toBe('grab'); + }); +}); diff --git a/src/app/components/TierTitle.test.jsx b/src/app/components/TierTitle.test.jsx new file mode 100644 index 0000000..162f2b3 --- /dev/null +++ b/src/app/components/TierTitle.test.jsx @@ -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(); + expect(screen.getByText('S Tier')).toBeInTheDocument(); + }); + + test('falls back to tier value when title prop is not provided', () => { + renderWithRouter(); + expect(screen.getByText('S')).toBeInTheDocument(); + }); + + test('shows the Add (+) button in non-readOnly mode', () => { + renderWithRouter(); + expect(screen.getByTitle('Add New')).toBeInTheDocument(); + }); + + test('hides the Add (+) button in readOnly mode', () => { + renderWithRouter(); + expect(screen.queryByTitle('Add New')).not.toBeInTheDocument(); + }); + + // ─── Edit mode activation ─────────────────────────────────────────────────── + + test('enters edit mode on double-click', () => { + renderWithRouter(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('S Tier')).toBeInTheDocument(); + // rerender must include the MemoryRouter wrapper because TierTitle calls useNavigate() + rerender( + + + + ); + expect(screen.getByText('S+ Tier')).toBeInTheDocument(); + }); +});