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