veza/apps/web/src/features/tracks/hooks/useTrackList.test.ts
senke 559cfbee3e refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion)
Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS
errors, 0 behaviour change beyond one incidental auth bugfix
flagged below.

storybook/no-redundant-story-name (23 → 0) — 14 stories files
  Storybook v7+ infers the story name from the variable name, so
  `name: 'Default'` next to `export const Default: Story = …` is
  pure noise. Removed only when the name was redundant ;
  preserved when the label was a French translation
  ('Par défaut', 'Chargement', 'Avec erreur', etc.) since those
  are intentional.

react-refresh/only-export-components (25 → 0) — 21 files
  Each warning marks a file that exports a React component AND a
  hook / context / constant / barrel re-export. Suppressed
  per-line with the suppression-with-justification pattern :
    // eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API
  The justification matters — every comment names the specific
  thing being co-located (hook / context / CVA constant / lazy
  registry / route config / test util / backward-compat barrel).
  Splitting these would create 21 new files for a HMR-only DX
  win that's already a non-issue in practice.

@typescript-eslint/no-non-null-assertion (139 → 0) — 43 files
  Distribution of fixes :
    ~85 cases : refactored to explicit guard
                `if (!x) throw new Error('invariant: …')`
                or hoisted into local with narrowing.
    ~36 cases : helper extraction (one tooltip test had 16
                `wrapper!` patterns reduced to a single
                `getWrapper()` helper).
    ~18 cases : suppressed with specific reason :
                static literal arrays where index is provably
                in bounds, mock fixtures with structural
                guarantees, filter-then-map patterns where the
                filter excludes the null branch.
  One incidental find : services/api/auth.ts threw on missing
  tokens but didn't guard `user` ; added the missing check while
  refactoring the `user!` to a guard.

baseline post-commit : 921 warnings, 0 errors, 0 TS errors.
The remaining buckets are no-restricted-syntax (757, design-system
guardrail), no-explicit-any (115), exhaustive-deps (49).

CI --max-warnings will be lowered to 921 in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:30:22 +02:00

762 lines
20 KiB
TypeScript

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');
});
});
});