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