diff --git a/inertia/components/library/download-progress-card.test.tsx b/inertia/components/library/download-progress-card.test.tsx new file mode 100644 index 0000000..9f29d3f --- /dev/null +++ b/inertia/components/library/download-progress-card.test.tsx @@ -0,0 +1,195 @@ +import { render, screen } from '@testing-library/react' +import { DownloadProgressCard } from './download-progress-card' +import type { ActiveDownloadInfo } from '@/hooks/use_active_downloads' + +// Mock Hugeicons +vi.mock('@hugeicons/react', () => ({ + HugeiconsIcon: ({ className }: { className?: string }) => ( + + ), +})) + +vi.mock('@hugeicons/core-free-icons', () => { + const m = (name: string) => ({ name }) + return { + Download01Icon: m('Download01Icon'), + Time01Icon: m('Time01Icon'), + HardDriveIcon: m('HardDriveIcon'), + } +}) + +function makeDownload(overrides: Partial = {}): ActiveDownloadInfo { + return { + title: 'Test Download', + progress: 50, + status: 'downloading', + size: 1024 * 1024 * 500, // 500 MB + remaining: 1024 * 1024 * 250, // 250 MB + eta: 3600, + downloadClient: 'SABnzbd', + ...overrides, + } +} + +describe('DownloadProgressCard', () => { + describe('rendering', () => { + it('renders nothing when downloads array is empty', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders a card when there are downloads', () => { + render() + expect(screen.getByText('Test Download')).toBeInTheDocument() + }) + + it('renders multiple download items', () => { + const downloads = [ + makeDownload({ title: 'Movie A' }), + makeDownload({ title: 'Movie B' }), + makeDownload({ title: 'Movie C' }), + ] + render() + expect(screen.getByText('Movie A')).toBeInTheDocument() + expect(screen.getByText('Movie B')).toBeInTheDocument() + expect(screen.getByText('Movie C')).toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render( + + ) + const card = container.firstElementChild + expect(card?.className).toContain('my-custom-class') + }) + }) + + describe('progress display', () => { + it('shows progress percentage for downloading items', () => { + render() + expect(screen.getByText('75%')).toBeInTheDocument() + }) + + it('rounds progress percentage', () => { + render() + expect(screen.getByText('34%')).toBeInTheDocument() + }) + + it('shows "Importing" instead of percentage when status is importing', () => { + render() + expect(screen.getByText('Importing')).toBeInTheDocument() + expect(screen.queryByText('50%')).not.toBeInTheDocument() + }) + }) + + describe('file size formatting', () => { + it('displays downloaded and total size', () => { + render( + + ) + expect(screen.getByText('512.0 MB / 1.00 GB')).toBeInTheDocument() + }) + + it('formats KB correctly', () => { + render( + + ) + expect(screen.getByText('300.0 KB / 500.0 KB')).toBeInTheDocument() + }) + + it('does not show size when size is null', () => { + render( + + ) + // No size text should appear - check that HardDrive icon section is not rendered + expect(screen.queryByText(/KB|MB|GB/)).not.toBeInTheDocument() + }) + + it('does not show size when remaining is null', () => { + render( + + ) + expect(screen.queryByText(/\//)).not.toBeInTheDocument() + }) + }) + + describe('ETA formatting', () => { + it('displays hours and minutes for large ETAs', () => { + render( + // 2h 5m + ) + expect(screen.getByText('2h 5m remaining')).toBeInTheDocument() + }) + + it('displays minutes and seconds for medium ETAs', () => { + render( + // 2m 5s + ) + expect(screen.getByText('2m 5s remaining')).toBeInTheDocument() + }) + + it('displays only seconds for small ETAs', () => { + render( + + ) + expect(screen.getByText('45s remaining')).toBeInTheDocument() + }) + + it('does not show ETA when eta is null', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + + it('does not show ETA when eta is 0 or negative', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + + it('does not show ETA when importing', () => { + render( + + ) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() + }) + }) + + describe('download client', () => { + it('displays download client name', () => { + render( + + ) + expect(screen.getByText('SABnzbd')).toBeInTheDocument() + }) + + it('does not show download client when null', () => { + render( + + ) + expect(screen.queryByText('SABnzbd')).not.toBeInTheDocument() + }) + }) +}) diff --git a/inertia/pages/auth/reset_password.test.tsx b/inertia/pages/auth/reset_password.test.tsx index 7c0df36..30479db 100644 --- a/inertia/pages/auth/reset_password.test.tsx +++ b/inertia/pages/auth/reset_password.test.tsx @@ -1,86 +1,194 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import ResetPassword from './reset_password' const mockPost = vi.fn() -let mockFlash: Record = {} +let mockProcessing = false +let mockFlash: { success?: string } = {} vi.mock('@inertiajs/react', () => ({ Head: ({ title }: { title: string }) => {title}, - Link: ({ href, children, ...props }: any) => ( - - {children} - - ), - usePage: () => ({ - props: { flash: mockFlash }, - }), - useForm: (defaults: any) => ({ - data: { ...defaults }, + Link: ({ children, href }: any) => {children}, + usePage: () => ({ props: { flash: mockFlash } }), + useForm: (initial: any) => ({ + data: { ...initial }, setData: vi.fn(), post: mockPost, - processing: false, + processing: mockProcessing, }), })) +// Mock HamsterLogo vi.mock('@/components/icons/hamster-logo', () => ({ HamsterLogo: () =>
, })) +beforeEach(() => { + vi.clearAllMocks() + mockProcessing = false + mockFlash = {} +}) + describe('ResetPassword', () => { - beforeEach(() => { - mockPost.mockClear() - mockFlash = {} - }) + const defaultProps = { + token: 'test-token-123', + } - it('renders form fields when no error', () => { - render() - expect(screen.getByLabelText('New Password')).toBeInTheDocument() - expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument() - }) + describe('rendering', () => { + it('renders the page title', () => { + render() + expect(document.querySelector('title')?.textContent).toBe('Reset Password') + }) + + it('renders the heading', () => { + render() + expect(screen.getByText('Reset your password')).toBeInTheDocument() + }) + + it('renders description text', () => { + render() + expect(screen.getByText('Enter your new password below.')).toBeInTheDocument() + }) + + it('renders password input field', () => { + render() + expect(screen.getByLabelText('New Password')).toBeInTheDocument() + }) + + it('renders password confirmation input field', () => { + render() + expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument() + }) + + it('renders submit button with correct text', () => { + render() + expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument() + }) - it('hides form when error prop is present', () => { - render() - expect(screen.getByText('Token has expired')).toBeInTheDocument() - expect(screen.getByText('Request a new link')).toHaveAttribute('href', '/forgot-password') - expect(screen.queryByLabelText('New Password')).not.toBeInTheDocument() + it('renders sign in link', () => { + render() + const signInLink = screen.getByText('Sign in') + expect(signInLink).toBeInTheDocument() + expect(signInLink.closest('a')).toHaveAttribute('href', '/login') + }) + + it('renders the hamster logo', () => { + render() + expect(screen.getByTestId('hamster-logo')).toBeInTheDocument() + }) }) - it('shows token error message but keeps form visible', () => { - render() - expect(screen.getByText('Invalid token')).toBeInTheDocument() - expect(screen.getByLabelText('New Password')).toBeInTheDocument() + describe('form submission', () => { + it('renders a submit button that triggers form submission', () => { + render() + const button = screen.getByRole('button', { name: 'Reset Password' }) + expect(button).toHaveAttribute('type', 'submit') + }) + + it('button is inside a form element', () => { + render() + const button = screen.getByRole('button', { name: 'Reset Password' }) + expect(button.closest('form')).toBeInTheDocument() + }) }) - it('shows field validation errors', () => { - render( - - ) - expect(screen.getByText('Password is too short')).toBeInTheDocument() - expect(screen.getByText('Passwords do not match')).toBeInTheDocument() + describe('processing state', () => { + it('shows "Resetting..." when processing', () => { + mockProcessing = true + render() + expect(screen.getByRole('button', { name: 'Resetting...' })).toBeInTheDocument() + }) + + it('disables button when processing', () => { + mockProcessing = true + render() + expect(screen.getByRole('button', { name: 'Resetting...' })).toBeDisabled() + }) }) - it('shows flash success message', () => { - mockFlash = { success: 'Password has been reset' } - render() - expect(screen.getByText('Password has been reset')).toBeInTheDocument() + describe('validation errors', () => { + it('displays password error', () => { + render( + + ) + expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument() + }) + + it('displays password confirmation error', () => { + render( + + ) + expect(screen.getByText('Passwords do not match')).toBeInTheDocument() + }) + + it('displays both errors simultaneously', () => { + render( + + ) + expect(screen.getByText('Too short')).toBeInTheDocument() + expect(screen.getByText('Does not match')).toBeInTheDocument() + }) }) - it('has Sign in link', () => { - render() - expect(screen.getByText('Sign in')).toHaveAttribute('href', '/login') + describe('token error', () => { + it('displays token error from errors prop', () => { + render( + + ) + expect(screen.getByText(/Token has expired/)).toBeInTheDocument() + }) + + it('displays error from error prop', () => { + render() + expect(screen.getByText(/Invalid or expired reset link/)).toBeInTheDocument() + }) + + it('hides the form when error prop is set', () => { + render() + expect(screen.queryByLabelText('New Password')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reset Password' })).not.toBeInTheDocument() + }) + + it('shows "Request a new link" link when token error is displayed', () => { + render() + const link = screen.getByText('Request a new link') + expect(link.closest('a')).toHaveAttribute('href', '/forgot-password') + }) + + it('still shows sign in link when token error is displayed', () => { + render() + expect(screen.getByText('Sign in')).toBeInTheDocument() + }) }) - it('submits form with post to /reset-password', () => { - render() - const form = document.querySelector('form')! - fireEvent.submit(form) - expect(mockPost).toHaveBeenCalledWith('/reset-password') + describe('flash messages', () => { + it('displays flash success message', () => { + mockFlash = { success: 'Your password has been reset successfully!' } + render() + expect(screen.getByText('Your password has been reset successfully!')).toBeInTheDocument() + }) + + it('does not show flash area when no flash message', () => { + mockFlash = {} + render() + expect( + screen.queryByText('Your password has been reset successfully!') + ).not.toBeInTheDocument() + }) }) }) diff --git a/inertia/pages/library/index.test.tsx b/inertia/pages/library/index.test.tsx index 5b4aa71..3c3bacc 100644 --- a/inertia/pages/library/index.test.tsx +++ b/inertia/pages/library/index.test.tsx @@ -1,101 +1,365 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import Library from './index' +// Mock Inertia vi.mock('@inertiajs/react', () => ({ Head: ({ title }: { title: string }) => {title}, - Link: ({ href, children, ...props }: any) => ( - - {children} - - ), router: { visit: vi.fn() }, + Link: ({ children, href }: any) => {children}, + usePage: () => ({ props: {} }), })) +// Mock layout vi.mock('@/components/layout', () => ({ - AppLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, + AppLayout: ({ children, actions }: { children: React.ReactNode; actions?: React.ReactNode }) => ( +
+ {actions &&
{actions}
} + {children} +
+ ), })) +// Mock Hugeicons vi.mock('@hugeicons/react', () => ({ HugeiconsIcon: ({ className }: { className?: string }) => ( ), })) -vi.mock('@hugeicons/core-free-icons', () => ({ - Add01Icon: { name: 'Add01Icon' }, - Search01Icon: { name: 'Search01Icon' }, - GridIcon: { name: 'GridIcon' }, - Menu01Icon: { name: 'Menu01Icon' }, - SortingIcon: { name: 'SortingIcon' }, - MusicNote01Icon: { name: 'MusicNote01Icon' }, - Film01Icon: { name: 'Film01Icon' }, - Tv01Icon: { name: 'Tv01Icon' }, - Book01Icon: { name: 'Book01Icon' }, - MoreVerticalIcon: { name: 'MoreVerticalIcon' }, - Delete02Icon: { name: 'Delete02Icon' }, - EyeIcon: { name: 'EyeIcon' }, - CheckmarkCircle02Icon: { name: 'CheckmarkCircle02Icon' }, - Download01Icon: { name: 'Download01Icon' }, - Clock01Icon: { name: 'Clock01Icon' }, - FolderSearchIcon: { name: 'FolderSearchIcon' }, -})) +vi.mock('@hugeicons/core-free-icons', () => { + const m = (name: string) => ({ name }) + return { + Add01Icon: m('Add01Icon'), + Search01Icon: m('Search01Icon'), + GridIcon: m('GridIcon'), + Menu01Icon: m('Menu01Icon'), + SortingIcon: m('SortingIcon'), + MusicNote01Icon: m('MusicNote01Icon'), + Film01Icon: m('Film01Icon'), + Tv01Icon: m('Tv01Icon'), + Book01Icon: m('Book01Icon'), + MoreVerticalIcon: m('MoreVerticalIcon'), + Delete02Icon: m('Delete02Icon'), + EyeIcon: m('EyeIcon'), + CheckmarkCircle02Icon: m('CheckmarkCircle02Icon'), + Download01Icon: m('Download01Icon'), + Clock01Icon: m('Clock01Icon'), + FolderSearchIcon: m('FolderSearchIcon'), + } +}) +// Mock sonner vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, })) +// Mock operation tracker vi.mock('@/hooks/use_operation_tracker', () => ({ - useOperationTrackerContext: () => ({ - operations: [], - isRunning: false, - }), + useOperationTrackerContext: () => ({ runBulk: vi.fn() }), })) +// Mock media status badge vi.mock('@/components/library/media-status-badge', () => ({ - MediaStatusBadge: ({ status }: { status: string }) => ( - {status} - ), - getMediaItemStatus: () => 'downloaded' as const, + MediaStatusBadge: () => null, + getMediaItemStatus: () => ({ status: 'none', progress: 0 }), })) +// Track fetch calls +let fetchMock: ReturnType + +const settingsResponse = (enabledTypes: string[]) => ({ + ok: true, + json: () => Promise.resolve({ enabledMediaTypes: enabledTypes }), +}) + +const moviesResponse = (movies: any[] = []) => ({ + ok: true, + json: () => Promise.resolve(movies), +}) + +const queueResponse = (queue: any[] = []) => ({ + ok: true, + json: () => Promise.resolve(queue), +}) + +const emptyOkResponse = () => ({ + ok: true, + json: () => Promise.resolve([]), +}) + +function setupFetchMock(options: { + enabledTypes?: string[] + movies?: any[] + artists?: any[] + tvShows?: any[] + authors?: any[] + queue?: any[] +} = {}) { + const { + enabledTypes = ['movies'], + movies = [], + artists = [], + tvShows = [], + authors = [], + queue = [], + } = options + + fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === '/api/v1/settings') return Promise.resolve(settingsResponse(enabledTypes)) + if (url === '/api/v1/queue') return Promise.resolve(queueResponse(queue)) + if (url === '/api/v1/movies') return Promise.resolve(moviesResponse(movies)) + if (url === '/api/v1/artists') return Promise.resolve(moviesResponse(artists)) + if (url === '/api/v1/tvshows') return Promise.resolve(moviesResponse(tvShows)) + if (url === '/api/v1/authors') return Promise.resolve(moviesResponse(authors)) + return Promise.resolve(emptyOkResponse()) + }) + global.fetch = fetchMock +} + beforeEach(() => { - // Return proper data shapes for each API call - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((url: string) => { - if (url.includes('/api/v1/settings')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ enabledMediaTypes: ['movies'] }), - }) - } - // All other endpoints return empty arrays - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([]), - }) - }) - ) + // Reset URL search params + window.history.replaceState({}, '', '/') + setupFetchMock() }) afterEach(() => { - vi.unstubAllGlobals() + vi.restoreAllMocks() }) describe('Library', () => { - it('renders without crashing', () => { - render() - expect(screen.getByText('Movies')).toBeInTheDocument() + describe('initial loading', () => { + it('renders the page title', async () => { + render() + expect(document.querySelector('title')?.textContent).toBe('Library') + }) + + it('shows loading skeletons initially', () => { + render() + // Skeleton cards are rendered during loading + const skeletons = document.querySelectorAll('[data-slot="skeleton"]') + expect(skeletons.length).toBeGreaterThan(0) + }) + + it('fetches settings on mount', async () => { + render() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/settings') + }) + }) + + it('fetches queue and movies data on mount', async () => { + render() + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/queue') + expect(fetchMock).toHaveBeenCalledWith('/api/v1/movies') + }) + }) + }) + + describe('tab rendering', () => { + it('renders tabs based on enabled media types from settings', async () => { + setupFetchMock({ enabledTypes: ['movies', 'tv', 'music'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Movies')).toBeInTheDocument() + expect(screen.getByText('TV Shows')).toBeInTheDocument() + expect(screen.getByText('Music')).toBeInTheDocument() + }) + }) + + it('always includes Missing tab', async () => { + setupFetchMock({ enabledTypes: ['movies'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Missing')).toBeInTheDocument() + }) + }) + + it('switches tab when clicked', async () => { + const user = userEvent.setup() + setupFetchMock({ enabledTypes: ['movies', 'music'] }) + render() + + await waitFor(() => { + expect(screen.getByText('Music')).toBeInTheDocument() + }) + + await user.click(screen.getByText('Music')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/v1/artists') + }) + }) }) - it('shows the default Movies tab', () => { - render() - expect(screen.getByText('Movies')).toBeInTheDocument() + describe('movies content', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt123', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + { id: 2, title: 'Avatar', year: 2009, posterUrl: null, tmdbId: 'tt456', requested: true, hasFile: false, status: 'Released', runtime: 162, rating: 7.9, overview: null }, + { id: 3, title: 'Blade Runner', year: 1982, posterUrl: null, tmdbId: 'tt789', requested: false, hasFile: false, status: 'Released', runtime: 117, rating: 8.1, overview: null }, + ] + + it('displays movie titles after loading', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + expect(screen.getByText('Avatar')).toBeInTheDocument() + expect(screen.getByText('Blade Runner')).toBeInTheDocument() + }) + }) + + it('shows total count in stats bar', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText(/Showing 3 of 3 movies/)).toBeInTheDocument() + }) + }) }) - it('shows loading state initially', () => { - render() - const skeletons = document.querySelectorAll('[data-slot="skeleton"]') - expect(skeletons.length).toBeGreaterThan(0) + describe('search filtering', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt1', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + { id: 2, title: 'Interstellar', year: 2014, posterUrl: null, tmdbId: 'tt2', requested: true, hasFile: true, status: 'Released', runtime: 169, rating: 8.7, overview: null }, + { id: 3, title: 'The Matrix', year: 1999, posterUrl: null, tmdbId: 'tt3', requested: true, hasFile: true, status: 'Released', runtime: 136, rating: 8.7, overview: null }, + ] + + it('filters items by search query', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'Inter') + + expect(screen.getByText('Interstellar')).toBeInTheDocument() + expect(screen.queryByText('Inception')).not.toBeInTheDocument() + expect(screen.queryByText('The Matrix')).not.toBeInTheDocument() + }) + + it('shows filtered count in stats bar', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'In') + + expect(screen.getByText(/Showing 2 of 3 movies/)).toBeInTheDocument() + }) + + it('shows empty state when no items match search', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText('Filter movies...') + await user.type(input, 'zzzznotfound') + + expect(screen.getByText('No items found')).toBeInTheDocument() + expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument() + }) + }) + + describe('empty library state', () => { + it('shows empty state message when no movies exist', async () => { + setupFetchMock({ movies: [] }) + render() + + await waitFor(() => { + expect(screen.getByText('Your movies library is empty')).toBeInTheDocument() + }) + }) + + it('shows add button in empty state', async () => { + setupFetchMock({ movies: [] }) + render() + + await waitFor(() => { + expect(screen.getByText(/Add Movie/)).toBeInTheDocument() + }) + }) + }) + + describe('view mode toggle', () => { + const sampleMovies = [ + { id: 1, title: 'Inception', year: 2010, posterUrl: null, tmdbId: 'tt1', requested: true, hasFile: true, status: 'Released', runtime: 148, rating: 8.8, overview: null }, + ] + + it('defaults to grid view', async () => { + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + // Grid view is rendered (grid has aspect-[2/3] containers) + const gridContainer = document.querySelector('.grid') + expect(gridContainer).toBeInTheDocument() + }) + + it('switches to list view when list button is clicked', async () => { + const user = userEvent.setup() + setupFetchMock({ movies: sampleMovies }) + render() + + await waitFor(() => { + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + + // There are two view mode buttons (grid and list). The list button is the second one + // in the view mode toggle group + const buttons = document.querySelectorAll('.rounded-l-none') + expect(buttons.length).toBe(1) + await user.click(buttons[0] as HTMLElement) + + // After switching, the content should still show the movie + expect(screen.getByText('Inception')).toBeInTheDocument() + }) + }) + + describe('sort options', () => { + it('shows sort dropdown button', async () => { + render() + // The sort button has text "Sort" + await waitFor(() => { + expect(screen.getByText('Sort')).toBeInTheDocument() + }) + }) + }) + + describe('scan library button', () => { + it('shows scan library button', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Scan Library')).toBeInTheDocument() + }) + }) + }) + + describe('add button', () => { + it('renders an Add button in the header', () => { + render() + expect(screen.getByText('Add')).toBeInTheDocument() + }) }) })