import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import { useTrackList } from './useTrackList'; import { getTracks } from '../services/trackListService'; import type { Track } from '../../player/types'; // Mock getTracks service vi.mock('../services/trackListService', () => ({ getTracks: vi.fn(), })); // Mock useSearchParams from react-router-dom const mockSetSearchParams = vi.fn(); const mockSearchParams = new URLSearchParams(); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useSearchParams: () => [mockSearchParams, mockSetSearchParams], }; }); const mockTracks: Track[] = [ { id: 1, title: 'Track A', artist: 'Artist 1', album: 'Album 1', duration: 180, url: 'https://example.com/track1.mp3', genre: 'Rock', }, { id: 2, title: 'Track B', artist: 'Artist 2', album: 'Album 2', duration: 240, url: 'https://example.com/track2.mp3', genre: 'Pop', }, { id: 3, title: 'Track C', artist: 'Artist 1', duration: 120, url: 'https://example.com/track3.mp3', genre: 'Rock', }, ]; describe('useTrackList', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockSearchParams.forEach((_, key) => { mockSearchParams.delete(key); }); }); afterEach(() => { localStorage.clear(); }); describe('Client-side mode (useService = false)', () => { it('should initialize with default values', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); expect(result.current.tracks).toEqual([]); expect(result.current.displayMode).toBe('list'); expect(result.current.sortOptions.field).toBe('title'); expect(result.current.sortOptions.order).toBe('asc'); expect(result.current.filterOptions).toEqual({}); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); expect(result.current.pagination).toEqual({ page: 1, limit: 20 }); }); it('should initialize with provided values', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, initialDisplayMode: 'grid', initialSortOptions: { field: 'duration', order: 'desc' }, initialFilterOptions: { genre: 'Rock' }, initialPagination: { page: 1, limit: 10 }, }), ); expect(result.current.tracks.length).toBeGreaterThan(0); expect(result.current.displayMode).toBe('grid'); expect(result.current.sortOptions.field).toBe('duration'); expect(result.current.sortOptions.order).toBe('desc'); expect(result.current.filterOptions.genre).toBe('Rock'); }); it('should filter tracks by genre', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, }), ); act(() => { result.current.setFilterOptions({ genre: 'Rock' }); }); expect(result.current.filteredTracks).toHaveLength(2); expect( result.current.filteredTracks.every((t) => t.genre === 'Rock'), ).toBe(true); }); it('should sort tracks by title', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, }), ); act(() => { result.current.setSortField('title'); result.current.setSortOrder('asc'); }); expect(result.current.filteredTracks[0].title).toBe('Track A'); }); it('should paginate tracks', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, initialPagination: { page: 1, limit: 2 }, }), ); expect(result.current.filteredTracks).toHaveLength(2); expect(result.current.total).toBe(3); expect(result.current.totalPages).toBe(2); }); }); describe('Service mode (useService = true)', () => { it('should load tracks from service on mount', async () => { const mockResponse = { data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); expect(result.current.isLoading).toBe(true); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(getTracks).toHaveBeenCalled(); expect(result.current.tracks).toEqual(mockTracks); expect(result.current.total).toBe(mockTracks.length); }); it('should not load tracks if autoLoad is false', () => { renderHook(() => useTrackList({ useService: true, autoLoad: false })); expect(getTracks).not.toHaveBeenCalled(); }); it('should reload tracks when pagination changes', async () => { const mockResponse1 = { data: [mockTracks[0]], total: mockTracks.length, page: 1, limit: 1, totalPages: 3, }; const mockResponse2 = { data: [mockTracks[1]], total: mockTracks.length, page: 2, limit: 1, totalPages: 3, }; vi.mocked(getTracks) .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true, initialPagination: { page: 1, limit: 1 }, }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); act(() => { result.current.setPage(2); }); await waitFor(() => { expect(getTracks).toHaveBeenCalledTimes(2); }); }); it('should reload tracks when filters change', async () => { const mockResponse = { data: [mockTracks[0], mockTracks[2]], total: 2, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); act(() => { result.current.setFilterOptions({ genre: 'Rock' }); }); await waitFor(() => { expect(getTracks).toHaveBeenCalledWith( expect.objectContaining({ filters: { genre: 'Rock' }, }), ); }); }); it('should reload tracks when sort changes', async () => { const mockResponse = { data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); act(() => { result.current.setSortField('duration'); }); await waitFor(() => { expect(getTracks).toHaveBeenCalledWith( expect.objectContaining({ sort: { field: 'duration', order: 'asc' }, }), ); }); }); it('should handle loading state', async () => { let resolvePromise: (value: any) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); vi.mocked(getTracks).mockReturnValue(promise as any); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); expect(result.current.isLoading).toBe(true); act(() => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above resolvePromise!({ data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }); }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); }); it('should handle errors', async () => { const error = new Error('Failed to load tracks'); vi.mocked(getTracks).mockRejectedValue(error); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.error).toEqual(error); expect(result.current.tracks).toEqual([]); }); it('should manually load tracks', async () => { const mockResponse = { data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: false }), ); await act(async () => { await result.current.loadTracks(); }); expect(getTracks).toHaveBeenCalled(); expect(result.current.tracks).toEqual(mockTracks); }); it('should refresh tracks', async () => { const mockResponse = { data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); const callCount = vi.mocked(getTracks).mock.calls.length; await act(async () => { await result.current.refreshTracks(); }); expect(getTracks).toHaveBeenCalledTimes(callCount + 1); }); it('should reset to page 1 when filters change', async () => { const mockResponse = { data: mockTracks, total: mockTracks.length, page: 1, limit: 20, totalPages: 1, }; vi.mocked(getTracks).mockResolvedValue(mockResponse); const { result } = renderHook(() => useTrackList({ useService: true, autoLoad: true, initialPagination: { page: 2, limit: 20 }, }), ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); act(() => { result.current.setFilterOptions({ genre: 'Rock' }); }); await waitFor(() => { expect(result.current.pagination.page).toBe(1); }); }); }); describe('CRUD operations', () => { it('should add track', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); act(() => { result.current.addTrack(mockTracks[0]); }); expect(result.current.tracks).toContain(mockTracks[0]); }); it('should remove track', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, }), ); act(() => { result.current.removeTrack(2); }); expect(result.current.tracks).toHaveLength(2); expect(result.current.tracks.find((t) => t.id === 2)).toBeUndefined(); }); it('should update track', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, }), ); const updatedTrack = { ...mockTracks[0], title: 'Updated Title' }; act(() => { result.current.updateTrack(updatedTrack); }); const updated = result.current.tracks.find( (t) => t.id === updatedTrack.id, ); expect(updated?.title).toBe('Updated Title'); }); }); describe('Pagination controls', () => { it('should set page', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); act(() => { result.current.setPage(3); }); expect(result.current.pagination.page).toBe(3); }); it('should set limit', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); act(() => { result.current.setLimit(50); }); expect(result.current.pagination.limit).toBe(50); expect(result.current.pagination.page).toBe(1); // Should reset to page 1 }); it('should set pagination', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); act(() => { result.current.setPagination({ page: 2, limit: 10 }); }); expect(result.current.pagination).toEqual({ page: 2, limit: 10 }); }); }); describe('Search', () => { it('should set search query', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false }), ); act(() => { result.current.setSearchQuery('test query'); }); // In client mode, search is handled in filteredTracks expect(result.current.filteredTracks).toBeDefined(); }); it('should filter tracks by search query in client mode', () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, initialTracks: mockTracks, }), ); act(() => { result.current.setSearchQuery('Track A'); }); expect(result.current.filteredTracks).toHaveLength(1); expect(result.current.filteredTracks[0].title).toBe('Track A'); }); }); describe('Persistence', () => { it('should persist filters to localStorage when persistFilters is enabled', async () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistFilters: true, }), ); act(() => { result.current.setFilterOptions({ genre: 'Rock', artist: 'Artist 1' }); }); await waitFor( () => { const stored = localStorage.getItem('trackList_filters'); expect(stored).toBeTruthy(); if (stored) { const parsed = JSON.parse(stored); expect(parsed.genre).toBe('Rock'); expect(parsed.artist).toBe('Artist 1'); } }, { timeout: 2000 }, ); }); it('should persist sort options to localStorage when persistSort is enabled', async () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistSort: true, }), ); act(() => { result.current.setSortField('artist'); result.current.setSortOrder('desc'); }); await waitFor( () => { const stored = localStorage.getItem('trackList_sort'); expect(stored).toBeTruthy(); if (stored) { const parsed = JSON.parse(stored); expect(parsed.field).toBe('artist'); expect(parsed.order).toBe('desc'); } }, { timeout: 2000 }, ); }); it('should restore filters from localStorage on mount when persistFilters is enabled', () => { localStorage.setItem( 'trackList_filters', JSON.stringify({ genre: 'Pop', artist: 'Artist 2' }), ); const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistFilters: true, }), ); expect(result.current.filterOptions.genre).toBe('Pop'); expect(result.current.filterOptions.artist).toBe('Artist 2'); }); it('should restore sort options from localStorage on mount when persistSort is enabled', () => { localStorage.setItem( 'trackList_sort', JSON.stringify({ field: 'duration', order: 'desc' }), ); const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistSort: true, }), ); expect(result.current.sortOptions.field).toBe('duration'); expect(result.current.sortOptions.order).toBe('desc'); }); it('should use custom storage key prefix', async () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistFilters: true, storageKeyPrefix: 'customPrefix', }), ); act(() => { result.current.setFilterOptions({ genre: 'Jazz' }); }); await waitFor( () => { const stored = localStorage.getItem('customPrefix_filters'); expect(stored).toBeTruthy(); if (stored) { const parsed = JSON.parse(stored); expect(parsed.genre).toBe('Jazz'); } }, { timeout: 2000 }, ); }); it('should sync filters with URL params when syncUrlParams is enabled', async () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, syncUrlParams: true, }), ); act(() => { result.current.setFilterOptions({ genre: 'Rock', artist: 'Artist 1' }); }); await waitFor( () => { expect(mockSetSearchParams).toHaveBeenCalled(); }, { timeout: 2000 }, ); }); it('should sync sort options with URL params when syncUrlParams is enabled', async () => { const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, syncUrlParams: true, }), ); act(() => { result.current.setSortField('artist'); result.current.setSortOrder('desc'); }); await waitFor( () => { expect(mockSetSearchParams).toHaveBeenCalled(); }, { timeout: 2000 }, ); }); it('should load filters from URL params when syncUrlParams is enabled', () => { mockSearchParams.set('genre', 'Pop'); mockSearchParams.set('artist', 'Artist 2'); mockSearchParams.set('year', '2020'); const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, syncUrlParams: true, }), ); expect(result.current.filterOptions.genre).toBe('Pop'); expect(result.current.filterOptions.artist).toBe('Artist 2'); expect(result.current.filterOptions.year).toBe(2020); }); it('should load sort options from URL params when syncUrlParams is enabled', () => { mockSearchParams.set('sortField', 'duration'); mockSearchParams.set('sortOrder', 'desc'); const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, syncUrlParams: true, }), ); expect(result.current.sortOptions.field).toBe('duration'); expect(result.current.sortOptions.order).toBe('desc'); }); it('should prioritize URL params over localStorage when both are enabled', () => { localStorage.setItem( 'trackList_filters', JSON.stringify({ genre: 'Rock' }), ); mockSearchParams.set('genre', 'Pop'); const { result } = renderHook(() => useTrackList({ useService: false, autoLoad: false, persistFilters: true, syncUrlParams: true, }), ); // URL params should take priority expect(result.current.filterOptions.genre).toBe('Pop'); }); }); });