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>
This commit is contained in:
senke 2026-04-30 23:30:22 +02:00
parent 12a78616df
commit 559cfbee3e
100 changed files with 386 additions and 234 deletions

View file

@ -124,14 +124,19 @@ jobs:
working-directory: apps/web working-directory: apps/web
- name: Lint - name: Lint
# ESLint warning baseline freeze (v1.0.10 polish): # ESLint warning baseline (v1.0.10 dette tech).
# 1204 is the current count of legacy warnings (mostly the # Lowered from 1204 → 1108 after no-unused-vars sprint
# custom no-restricted-syntax 721, plus @typescript-eslint # (134 → 0). Top contributors at this baseline :
# no-non-null-assertion 139, no-unused-vars 134, # 757 no-restricted-syntax (custom design-system rule —
# no-explicit-any 115, react-hooks/exhaustive-deps 47). # Tailwind defaults / hex literals / native <button>)
# CI fails on ANY new warning. Lower this number as warnings # 139 @typescript-eslint/no-non-null-assertion
# are resorbed by feature work; never raise it. # 115 @typescript-eslint/no-explicit-any
run: npx eslint --max-warnings=1204 . # 47 react-hooks/exhaustive-deps
# 25 react-refresh/only-export-components
# 23 storybook/no-redundant-story-name
# CI fails on ANY new warning. Lower this number as
# warnings are resorbed ; never raise it.
run: npx eslint --max-warnings=1108 .
working-directory: apps/web working-directory: apps/web
- name: Typecheck - name: Typecheck

View file

@ -13,6 +13,7 @@ const ENDPOINTS = [
export const APIPlaygroundView: React.FC = () => { export const APIPlaygroundView: React.FC = () => {
const { addToast } = useToast(); const { addToast } = useToast();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- ENDPOINTS is a non-empty static literal
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!); const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!);
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}'); const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
const [response, setResponse] = useState<string | null>(null); const [response, setResponse] = useState<string | null>(null);

View file

@ -19,6 +19,7 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | undefined>(undefined); const ToastContext = createContext<ToastContextValue | undefined>(undefined);
// Export hooks for usage // Export hooks for usage
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the provider's context
export function useToastContext() { export function useToastContext() {
const context = useContext(ToastContext); const context = useContext(ToastContext);
if (!context) { if (!context) {
@ -31,6 +32,7 @@ export function useToastContext() {
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead. * @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
* Legacy compatibility hook delegates to react-hot-toast. Works without ToastProvider. * Legacy compatibility hook delegates to react-hot-toast. Works without ToastProvider.
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- co-located deprecated hook; kept here until consumers migrate to @/hooks/useToast
export function useToast() { export function useToast() {
const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => { const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => {
if (typeof messageOrToast === 'string') { if (typeof messageOrToast === 'string') {

View file

@ -61,7 +61,6 @@ export const Default: Story = {
* v0.102: QueueView lit le store synchrone ; le chargement réel se fait via useQueueSync au layout. * v0.102: QueueView lit le store synchrone ; le chargement réel se fait via useQueueSync au layout.
*/ */
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
decorators: [ decorators: [
(_Story) => { (_Story) => {
usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null }); usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null });

View file

@ -28,7 +28,6 @@ const mockLicense = {
}; };
export const Basic: Story = { export const Basic: Story = {
name: 'Basic',
args: { args: {
license: mockLicense, license: mockLicense,
onSelect: () => { }, onSelect: () => { },
@ -38,7 +37,6 @@ export const Basic: Story = {
export const Pro: Story = { export const Pro: Story = {
name: 'Pro',
args: { args: {
license: { ...mockLicense, name: 'Pro Lease', price: 49.99, isPopular: true }, license: { ...mockLicense, name: 'Pro Lease', price: 49.99, isPopular: true },
onSelect: () => { }, onSelect: () => { },
@ -47,7 +45,6 @@ export const Pro: Story = {
}; };
export const Exclusive: Story = { export const Exclusive: Story = {
name: 'Exclusive',
args: { args: {
license: { ...mockLicense, name: 'Exclusive Rights', price: 199.99, features: ['Unlimited Streams', 'Trackout Stems', 'Full Ownership'] }, license: { ...mockLicense, name: 'Exclusive Rights', price: 199.99, features: ['Unlimited Streams', 'Trackout Stems', 'Full Ownership'] },
onSelect: () => { }, onSelect: () => { },

View file

@ -76,7 +76,6 @@ export const Loading: Story = {
/** Empty similar products */ /** Empty similar products */
export const Empty: Story = { export const Empty: Story = {
name: 'Empty',
args: { args: {
product: mockProduct, product: mockProduct,
similarProducts: [], similarProducts: [],

View file

@ -49,6 +49,7 @@ export function Breadcrumbs({
<li key={itemKey} className="flex items-center gap-1 sm:gap-2"> <li key={itemKey} className="flex items-center gap-1 sm:gap-2">
{isClickable ? ( {isClickable ? (
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- isClickable narrows item.href to truthy
to={item.href!} to={item.href!}
className={cn( className={cn(
'flex items-center gap-1 text-sm font-medium text-muted-foreground', 'flex items-center gap-1 text-sm font-medium text-muted-foreground',

View file

@ -2,5 +2,6 @@
* Re-export from audio-player module. * Re-export from audio-player module.
* @see apps/web/src/components/player/audio-player/ * @see apps/web/src/components/player/audio-player/
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; formatTime is a utility function co-located with the AudioPlayer component
export { AudioPlayer, AudioPlayerSkeleton, formatTime } from './audio-player'; export { AudioPlayer, AudioPlayerSkeleton, formatTime } from './audio-player';
export type { AudioPlayerProps } from './audio-player'; export type { AudioPlayerProps } from './audio-player';

View file

@ -9,13 +9,14 @@ export const LyricsPanel: React.FC = () => {
// Auto-scroll logic // Auto-scroll logic
useEffect(() => { useEffect(() => {
if (autoScroll && scrollRef.current && currentTrack?.lyrics) { const lyrics = currentTrack?.lyrics;
const activeIndex = currentTrack.lyrics.findIndex( if (autoScroll && scrollRef.current && lyrics) {
const activeIndex = lyrics.findIndex(
(line: { time: number; text: string }, i: number) => { (line: { time: number; text: string }, i: number) => {
return ( return (
currentTime >= line.time && currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 || (i === lyrics.length - 1 ||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity)) currentTime < (lyrics[i + 1]?.time ?? Infinity))
); );
}, },
); );
@ -59,10 +60,11 @@ export const LyricsPanel: React.FC = () => {
> >
{currentTrack.lyrics.map( {currentTrack.lyrics.map(
(line: { time: number; text: string }, i: number) => { (line: { time: number; text: string }, i: number) => {
const lyrics = currentTrack.lyrics ?? [];
const isActive = const isActive =
currentTime >= line.time && currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 || (i === lyrics.length - 1 ||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity)); currentTime < (lyrics[i + 1]?.time ?? Infinity));
return ( return (
<p <p
key={i} key={i}

View file

@ -23,6 +23,5 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {}; export const Default: Story = {};
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <CreateProductViewSkeleton />, render: () => <CreateProductViewSkeleton />,
}; };

View file

@ -38,6 +38,5 @@ export const Default: Story = {
}; };
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <AccountSettingsSkeleton />, render: () => <AccountSettingsSkeleton />,
}; };

View file

@ -336,6 +336,7 @@ export function ThemeProvider({
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
} }
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to ThemeContext defined in this file
export const useTheme = () => { export const useTheme = () => {
const context = useContext(ThemeContext); const context = useContext(ThemeContext);
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');

View file

@ -1,4 +1,5 @@
export { export {
// eslint-disable-next-line react-refresh/only-export-components -- lazy-component registry
createLazyComponent, createLazyComponent,
LazyErrorFallback, LazyErrorFallback,
LazyErrorBoundary, LazyErrorBoundary,

View file

@ -19,7 +19,8 @@ describe('WaveformVisualizer Component', () => {
it('calls onSeek when canvas is clicked', () => { it('calls onSeek when canvas is clicked', () => {
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />); const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
const canvas = container.querySelector('canvas')!; const canvas = container.querySelector('canvas');
if (!canvas) throw new Error('canvas not found');
// Mock getBoundingClientRect // Mock getBoundingClientRect
const mockRect = { const mockRect = {
@ -69,7 +70,8 @@ describe('WaveformVisualizer Component', () => {
it('clamps seek percentage between 0 and 100', () => { it('clamps seek percentage between 0 and 100', () => {
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />); const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
const canvas = container.querySelector('canvas')!; const canvas = container.querySelector('canvas');
if (!canvas) throw new Error('canvas not found');
const mockRect = { const mockRect = {
left: 0, left: 0,
top: 0, top: 0,

View file

@ -138,4 +138,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = 'Button'; Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Button component API
export { Button, buttonVariants }; export { Button, buttonVariants };

View file

@ -181,5 +181,6 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Card component API
cardVariants, cardVariants,
} }

View file

@ -109,7 +109,8 @@ describe('Dropdown Component', () => {
); );
// The trigger is a div with role="button", so click it // The trigger is a div with role="button", so click it
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!; const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
if (!triggerButton) throw new Error('triggerButton not found');
fireEvent.click(triggerButton); fireEvent.click(triggerButton);
await waitFor(() => { await waitFor(() => {
@ -125,7 +126,8 @@ describe('Dropdown Component', () => {
); );
// The trigger is a div with role="button", so click it // The trigger is a div with role="button", so click it
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!; const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
if (!triggerButton) throw new Error('triggerButton not found');
fireEvent.click(triggerButton); fireEvent.click(triggerButton);
await waitFor(() => { await waitFor(() => {

View file

@ -144,13 +144,14 @@ describe('FileUpload Component', () => {
.getByText('Drag & drop files here, or click to select') .getByText('Drag & drop files here, or click to select')
.closest('.border-2'); .closest('.border-2');
expect(dropZone).toBeInTheDocument(); expect(dropZone).toBeInTheDocument();
if (!dropZone) throw new Error('dropZone not found');
const file = new File(['content'], 'test.txt', { type: 'text/plain' }); const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const dataTransfer = { const dataTransfer = {
files: [file], files: [file],
}; };
fireEvent.dragEnter(dropZone!, { fireEvent.dragEnter(dropZone, {
dataTransfer, dataTransfer,
}); });
@ -158,7 +159,7 @@ describe('FileUpload Component', () => {
expect(dropZone).toHaveClass('border-primary'); expect(dropZone).toHaveClass('border-primary');
}); });
fireEvent.drop(dropZone!, { fireEvent.drop(dropZone, {
dataTransfer, dataTransfer,
}); });

View file

@ -6,6 +6,7 @@ export {
OptimizedImage, OptimizedImage,
OptimizedImageSkeleton, OptimizedImageSkeleton,
ResponsiveImage, ResponsiveImage,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the optimized-image module
useImagePreloader, useImagePreloader,
} from './optimized-image/index'; } from './optimized-image/index';
export type { OptimizedImageProps } from './optimized-image/index'; export type { OptimizedImageProps } from './optimized-image/index';

View file

@ -77,7 +77,8 @@ describe('RadioGroup Component', () => {
const input = el.querySelector('input[type="radio"]'); const input = el.querySelector('input[type="radio"]');
return input?.getAttribute('value') === 'option2'; return input?.getAttribute('value') === 'option2';
}); });
fireEvent.click(option2Label!); if (!option2Label) throw new Error('option2Label not found');
fireEvent.click(option2Label);
expect(handleChange).toHaveBeenCalledWith('option2'); expect(handleChange).toHaveBeenCalledWith('option2');
}); });

View file

@ -65,7 +65,8 @@ export function SelectDropdownContent({
case ' ': case ' ':
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) { if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
e.preventDefault(); e.preventDefault();
onSelect(allOptions[highlightedIndex]!.value); const opt = allOptions[highlightedIndex];
if (opt) onSelect(opt.value);
} }
break; break;
case 'Home': case 'Home':

View file

@ -17,8 +17,9 @@ export function useSelect({
const ungrouped: SelectOption[] = []; const ungrouped: SelectOption[] = [];
options.forEach((option) => { options.forEach((option) => {
if (option.group) { if (option.group) {
if (!groups[option.group]) groups[option.group] = []; const group = groups[option.group] ?? [];
groups[option.group]!.push(option); group.push(option);
groups[option.group] = group;
} else { } else {
ungrouped.push(option); ungrouped.push(option);
} }

View file

@ -3,6 +3,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { Tooltip } from './tooltip'; import { Tooltip } from './tooltip';
// Helper: querySelector that throws on null (Tooltip always renders this wrapper).
function getWrapper(container: HTMLElement): Element {
const el = container.querySelector('.relative.inline-block');
if (!el) throw new Error('tooltip wrapper not found');
return el;
}
describe('Tooltip Component', () => { describe('Tooltip Component', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -41,8 +48,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(310); vi.advanceTimersByTime(310);
@ -58,8 +65,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -67,7 +74,7 @@ describe('Tooltip Component', () => {
expect(screen.getByText('Tooltip text')).toBeInTheDocument(); expect(screen.getByText('Tooltip text')).toBeInTheDocument();
fireEvent.mouseLeave(wrapper!); fireEvent.mouseLeave(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(250); vi.advanceTimersByTime(250);
}); });
@ -86,8 +93,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -103,8 +110,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -121,8 +128,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -139,8 +146,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -157,8 +164,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -192,8 +199,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -209,8 +216,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -227,8 +234,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
@ -245,14 +252,14 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(200); vi.advanceTimersByTime(200);
}); });
fireEvent.mouseLeave(wrapper!); fireEvent.mouseLeave(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(200); vi.advanceTimersByTime(200);
@ -362,8 +369,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
}); });
@ -380,8 +387,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
}); });
@ -423,8 +430,8 @@ describe('Tooltip Component', () => {
</Tooltip>, </Tooltip>,
); );
const wrapper = container.querySelector('.relative.inline-block'); const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper!); fireEvent.mouseEnter(wrapper);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(10); vi.advanceTimersByTime(10);
}); });

View file

@ -1,6 +1,8 @@
export type { VirtualizedListProps } from './virtualized-list/index'; export type { VirtualizedListProps } from './virtualized-list/index';
export { export {
VirtualizedList, VirtualizedList,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
useInfiniteScroll, useInfiniteScroll,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
useScrollPosition, useScrollPosition,
} from './virtualized-list/index'; } from './virtualized-list/index';

View file

@ -56,8 +56,8 @@ export const VirtualizedList = React.forwardRef<
0, 0,
Math.floor(scrollTop / itemHeight) - overscan, Math.floor(scrollTop / itemHeight) - overscan,
); );
const endIndex = virtualItems[virtualItems.length - 1]!.index; const last = virtualItems[virtualItems.length - 1];
onItemsRendered(startIndex, endIndex); if (last) onItemsRendered(startIndex, last.index);
} }
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]); }, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);

View file

@ -42,7 +42,8 @@ export function useBatchUpload<T>(
const processNext = () => { const processNext = () => {
while (active < MAX_PARALLEL && queue.length > 0) { while (active < MAX_PARALLEL && queue.length > 0) {
const item = queue.shift()!; const item = queue.shift();
if (!item) continue;
if (cancelRef.current.has(item.id)) continue; if (cancelRef.current.has(item.id)) continue;
active++; active++;

View file

@ -1,2 +1,3 @@
export type { VisualizerSettings } from './audio-context'; export type { VisualizerSettings } from './audio-context';
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; useAudio hook is tightly coupled to the AudioProvider context
export { useAudio, AudioProvider } from './audio-context'; export { useAudio, AudioProvider } from './audio-context';

View file

@ -4,6 +4,7 @@ import { useAudioContextValue } from './useAudioContextValue';
const AudioContext = createContext<AudioContextType | undefined>(undefined); const AudioContext = createContext<AudioContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to AudioContext defined in this file
export const useAudio = () => { export const useAudio = () => {
const context = useContext(AudioContext); const context = useContext(AudioContext);
if (!context) throw new Error('useAudio must be used within AudioProvider'); if (!context) throw new Error('useAudio must be used within AudioProvider');

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <AnalyticsViewSkeleton />, render: () => <AnalyticsViewSkeleton />,
}; };

View file

@ -62,14 +62,16 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
</div> </div>
)} )}
{data.top_listening_times && data.top_listening_times.length > 0 && ( {data.top_listening_times && data.top_listening_times.length > 0 && (() => {
const times = data.top_listening_times;
return (
<div> <div>
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-3 flex items-center gap-1"> <p className="text-muted-foreground text-xs uppercase tracking-wider mb-3 flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" /> Peak listening hours <Clock className="w-3 h-3" aria-hidden="true" /> Peak listening hours
</p> </p>
<div className="grid grid-cols-6 gap-1" role="list" aria-label="Listening hours distribution"> <div className="grid grid-cols-6 gap-1" role="list" aria-label="Listening hours distribution">
{data.top_listening_times.map((slot) => { {times.map((slot) => {
const maxPlays = Math.max(...data.top_listening_times!.map((s) => s.play_count)); const maxPlays = Math.max(...times.map((s) => s.play_count));
const intensity = maxPlays > 0 ? slot.play_count / maxPlays : 0; const intensity = maxPlays > 0 ? slot.play_count / maxPlays : 0;
return ( return (
<div <div
@ -90,7 +92,8 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
})} })}
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
)} )}
</Card> </Card>

View file

@ -74,14 +74,16 @@ export function AnalyticsViewMarketplace({ data }: Props) {
)} )}
{/* Revenue timeline */} {/* Revenue timeline */}
{data.revenue_timeline && data.revenue_timeline.length > 0 && ( {data.revenue_timeline && data.revenue_timeline.length > 0 && (() => {
const timeline = data.revenue_timeline;
const maxRevenue = Math.max(...timeline.map((d) => d.revenue), 1);
return (
<div className="rounded-xl border border-white/5 bg-card p-6"> <div className="rounded-xl border border-white/5 bg-card p-6">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
Revenue Timeline Revenue Timeline
</h3> </h3>
<div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart"> <div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart">
{data.revenue_timeline.map((day, i) => { {timeline.map((day, i) => {
const maxRevenue = Math.max(...data.revenue_timeline!.map((d) => d.revenue), 1);
const height = (day.revenue / maxRevenue) * 100; const height = (day.revenue / maxRevenue) * 100;
return ( return (
<div <div
@ -102,7 +104,8 @@ export function AnalyticsViewMarketplace({ data }: Props) {
})} })}
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
); );
} }

View file

@ -45,7 +45,10 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
</div> </div>
{/* Revenue over time */} {/* Revenue over time */}
{data.revenue_by_period && data.revenue_by_period.length > 0 && ( {data.revenue_by_period && data.revenue_by_period.length > 0 && (() => {
const periods = data.revenue_by_period;
const maxRev = Math.max(...periods.map((p) => p.revenue));
return (
<Card variant="glass" className="p-6 bg-black/40 border-white/5"> <Card variant="glass" className="p-6 bg-black/40 border-white/5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-foreground text-sm uppercase tracking-widest flex items-center gap-2"> <h3 className="font-bold text-foreground text-sm uppercase tracking-widest flex items-center gap-2">
@ -62,8 +65,7 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
)} )}
</div> </div>
<div className="space-y-2" role="list" aria-label="Daily revenue"> <div className="space-y-2" role="list" aria-label="Daily revenue">
{data.revenue_by_period.slice(-14).map((period) => { {periods.slice(-14).map((period) => {
const maxRev = Math.max(...data.revenue_by_period!.map((p) => p.revenue));
const width = maxRev > 0 ? (period.revenue / maxRev) * 100 : 0; const width = maxRev > 0 ? (period.revenue / maxRev) * 100 : 0;
return ( return (
<div key={period.date} className="flex items-center gap-3 text-xs" role="listitem"> <div key={period.date} className="flex items-center gap-3 text-xs" role="listitem">
@ -82,7 +84,8 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
})} })}
</div> </div>
</Card> </Card>
)} );
})()}
{/* Top selling tracks */} {/* Top selling tracks */}
{hasRevenue && data.top_selling_tracks && data.top_selling_tracks.length > 0 && ( {hasRevenue && data.top_selling_tracks && data.top_selling_tracks.length > 0 && (

View file

@ -32,7 +32,6 @@ export const Default: Story = { name: 'Par défaut' };
/** Skeleton pendant chargement (layout aligné). */ /** Skeleton pendant chargement (layout aligné). */
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <RegisterPageSkeleton />, render: () => <RegisterPageSkeleton />,
}; };

View file

@ -37,13 +37,11 @@ export const Default: Story = {
/** État de chargement (skeleton). */ /** État de chargement (skeleton). */
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <SessionsPageSkeleton />, render: () => <SessionsPageSkeleton />,
}; };
/** Liste vide (override via MSW ou props). */ /** Liste vide (override via MSW ou props). */
export const Empty: Story = { export const Empty: Story = {
name: 'Empty',
args: { args: {
initialSessions: [], initialSessions: [],
}, },
@ -51,7 +49,6 @@ export const Empty: Story = {
/** Erreur chargement sessions */ /** Erreur chargement sessions */
export const Error: Story = { export const Error: Story = {
name: 'Error',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [

View file

@ -67,7 +67,9 @@ describe('ChatInput Component', () => {
render(<ChatInput />); render(<ChatInput />);
const input = screen.getByPlaceholderText(/Broadcast message/); const input = screen.getByPlaceholderText(/Broadcast message/);
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.submit(input.closest('form')!); const form = input.closest('form');
if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(mockSendMessage).toHaveBeenCalledWith('Hello', undefined, null); expect(mockSendMessage).toHaveBeenCalledWith('Hello', undefined, null);
}); });
@ -75,14 +77,17 @@ describe('ChatInput Component', () => {
render(<ChatInput />); render(<ChatInput />);
const input = screen.getByPlaceholderText(/Broadcast message/); const input = screen.getByPlaceholderText(/Broadcast message/);
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.submit(input.closest('form')!); const form = input.closest('form');
if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(input).toHaveValue(''); expect(input).toHaveValue('');
}); });
it('does not submit when input is empty and no attachments', () => { it('does not submit when input is empty and no attachments', () => {
render(<ChatInput />); render(<ChatInput />);
const form = screen.getByPlaceholderText(/Broadcast message/).closest('form'); const form = screen.getByPlaceholderText(/Broadcast message/).closest('form');
fireEvent.submit(form!); if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(mockSendMessage).not.toHaveBeenCalled(); expect(mockSendMessage).not.toHaveBeenCalled();
}); });

View file

@ -34,6 +34,5 @@ export const ProductionRoom: Story = {
}; };
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <ChatInterfaceSkeleton />, render: () => <ChatInterfaceSkeleton />,
}; };

View file

@ -90,7 +90,8 @@ export function MentionAutocomplete({
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length); setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Enter' && show) { } else if (e.key === 'Enter' && show) {
e.preventDefault(); e.preventDefault();
handleSelect(filtered[selectedIndex]!.username); const target = filtered[selectedIndex];
if (target) handleSelect(target.username);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setShow(false); setShow(false);
} }

View file

@ -1,5 +1,6 @@
export { export {
VirtualizedChatMessages, VirtualizedChatMessages,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-chat-messages module
useChatMessages, useChatMessages,
VirtualizedChatMessagesSkeleton, VirtualizedChatMessagesSkeleton,
} from './virtualized-chat-messages'; } from './virtualized-chat-messages';

View file

@ -183,10 +183,9 @@ export const useChatStore = create<ChatState>()(
}), }),
addMessage: (message) => addMessage: (message) =>
set((state) => { set((state) => {
if (!state.messages[message.conversation_id]) { const list = state.messages[message.conversation_id] ?? [];
state.messages[message.conversation_id] = []; list.push(message);
} state.messages[message.conversation_id] = list;
state.messages[message.conversation_id]!.push(message);
}), }),
loadMessages: (conversationId, newMessages) => loadMessages: (conversationId, newMessages) =>
set((state) => { set((state) => {
@ -224,21 +223,19 @@ export const useChatStore = create<ChatState>()(
if (messages) { if (messages) {
const message = messages.find((m) => m.id === messageId); const message = messages.find((m) => m.id === messageId);
if (message) { if (message) {
if (!message.reactions) message.reactions = {}; const reactions = message.reactions ?? {};
message.reactions = reactions;
// Remove existing reaction from this user if any // Remove existing reaction from this user if any
Object.keys(message.reactions).forEach((e) => { Object.keys(reactions).forEach((e) => {
const users = message.reactions![e]; const users = reactions[e];
if (!users) return; if (!users) return;
message.reactions![e] = users.filter( reactions[e] = users.filter((id) => id !== userId);
(id) => id !== userId, if (reactions[e]?.length === 0) delete reactions[e];
);
if (message.reactions![e]?.length === 0)
delete message.reactions![e];
}); });
// Add new reaction // Add new reaction
if (!message.reactions[emoji]) message.reactions[emoji] = []; if (!reactions[emoji]) reactions[emoji] = [];
if (!message.reactions[emoji].includes(userId)) { if (!reactions[emoji].includes(userId)) {
message.reactions[emoji].push(userId); reactions[emoji].push(userId);
} }
} }
} }
@ -249,12 +246,13 @@ export const useChatStore = create<ChatState>()(
if (messages) { if (messages) {
const message = messages.find((m) => m.id === messageId); const message = messages.find((m) => m.id === messageId);
if (message && message.reactions) { if (message && message.reactions) {
const emojisToRemove = emoji ? [emoji] : Object.keys(message.reactions); const reactions = message.reactions;
const emojisToRemove = emoji ? [emoji] : Object.keys(reactions);
emojisToRemove.forEach((e) => { emojisToRemove.forEach((e) => {
const users = message.reactions![e]; const users = reactions[e];
if (!users) return; if (!users) return;
message.reactions![e] = users.filter((id) => id !== userId); reactions[e] = users.filter((id) => id !== userId);
if (message.reactions![e]?.length === 0) delete message.reactions![e]; if (reactions[e]?.length === 0) delete reactions[e];
}); });
} }
} }

View file

@ -16,7 +16,10 @@ export function CheckoutCompletePage() {
const { data: order, isLoading, error } = useQuery({ const { data: order, isLoading, error } = useQuery({
queryKey: ['order', orderId], queryKey: ['order', orderId],
queryFn: () => marketplaceService.getOrder(orderId!), queryFn: () => {
if (!orderId) throw new Error('orderId required');
return marketplaceService.getOrder(orderId);
},
enabled: !!orderId, enabled: !!orderId,
}); });

View file

@ -70,11 +70,13 @@ export function DiscoverPage() {
refetch: refetchGenre, refetch: refetchGenre,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ['discoverGenre', browseGenre], queryKey: ['discoverGenre', browseGenre],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) => {
discoverService.getTracksByGenre(browseGenre!, { if (!browseGenre) throw new Error('browseGenre required');
return discoverService.getTracksByGenre(browseGenre, {
cursor: pageParam, cursor: pageParam,
limit: 20, limit: 20,
}), });
},
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
enabled: !!browseGenre, enabled: !!browseGenre,
@ -90,11 +92,13 @@ export function DiscoverPage() {
refetch: refetchTag, refetch: refetchTag,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ['discoverTag', browseTag], queryKey: ['discoverTag', browseTag],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) => {
discoverService.getTracksByTag(browseTag!, { if (!browseTag) throw new Error('browseTag required');
return discoverService.getTracksByTag(browseTag, {
cursor: pageParam, cursor: pageParam,
limit: 20, limit: 20,
}), });
},
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
enabled: !!browseTag, enabled: !!browseTag,

View file

@ -39,6 +39,7 @@ export const WithImages: Story = {
}; };
export const SingleImage: Story = { export const SingleImage: Story = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockImages declared above with entries
args: { images: [mockImages[0]!] }, args: { images: [mockImages[0]!] },
}; };

View file

@ -49,7 +49,10 @@ export function GearImageGallery({
{onRemoveImage && sorted[selected] && ( {onRemoveImage && sorted[selected] && (
<button <button
type="button" type="button"
onClick={() => onRemoveImage(sorted[selected]!.id)} onClick={() => {
const img = sorted[selected];
if (img) onRemoveImage(img.id);
}}
className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors" className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors"
aria-label="Remove image" aria-label="Remove image"
> >

View file

@ -23,6 +23,7 @@ export const Default: Story = {
export const WarrantyExpiringSoon: Story = { export const WarrantyExpiringSoon: Story = {
args: { args: {
item: { item: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockGearInventory has entries
...mockGearInventory[0]!, ...mockGearInventory[0]!,
warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
}, },

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <LiveViewSkeleton />, render: () => <LiveViewSkeleton />,
}; };

View file

@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
/** Loading state — skeleton to avoid layout shift */ /** Loading state — skeleton to avoid layout shift */
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <NotificationsPageSkeleton />, render: () => <NotificationsPageSkeleton />,
}; };
/** Empty list */ /** Empty list */
export const Empty: Story = { export const Empty: Story = {
name: 'Empty',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [
@ -53,7 +51,6 @@ export const Empty: Story = {
/** Error loading notifications */ /** Error loading notifications */
export const Error: Story = { export const Error: Story = {
name: 'Error',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [

View file

@ -45,6 +45,7 @@ function groupNotificationsByDate(
// Return in stable order defined by DATE_GROUP_ORDER // Return in stable order defined by DATE_GROUP_ORDER
return DATE_GROUP_ORDER.filter((g) => groups.has(g)).map((g) => [ return DATE_GROUP_ORDER.filter((g) => groups.has(g)).map((g) => [
g, g,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by groups.has(g) in the filter above
groups.get(g)!, groups.get(g)!,
]); ]);
} }

View file

@ -83,7 +83,10 @@ export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
// Poll session when active (v0.203 Lot D1) // Poll session when active (v0.203 Lot D1)
const { data: sessionData } = useQuery({ const { data: sessionData } = useQuery({
queryKey: ['queue-session', activeSessionToken], queryKey: ['queue-session', activeSessionToken],
queryFn: () => queueApi.getQueueSession(activeSessionToken!), queryFn: () => {
if (!activeSessionToken) throw new Error('activeSessionToken required');
return queueApi.getQueueSession(activeSessionToken);
},
enabled: !!activeSessionToken && isOpen, enabled: !!activeSessionToken && isOpen,
refetchInterval: 8000, refetchInterval: 8000,
}); });

View file

@ -55,9 +55,12 @@ export function shuffleTracks(tracks: Track[]): Track[] {
const shuffled = [...tracks]; const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
const temp = shuffled[i]!; const a = shuffled[i];
shuffled[i] = shuffled[j]!; const b = shuffled[j];
shuffled[j] = temp; if (a !== undefined && b !== undefined) {
shuffled[i] = b;
shuffled[j] = a;
}
} }
return shuffled; return shuffled;
} }
@ -358,7 +361,7 @@ export class AudioPlayerService {
step++; step++;
if (step >= steps) { if (step >= steps) {
clearInterval(id); clearInterval(id);
this.audioElement!.volume = 0; if (this.audioElement) this.audioElement.volume = 0;
onComplete(); onComplete();
return; return;
} }

View file

@ -1,6 +1,7 @@
export { export {
PlaylistAnalytics, PlaylistAnalytics,
PlaylistAnalyticsSkeleton, PlaylistAnalyticsSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the playlist-analytics module
usePlaylistAnalytics, usePlaylistAnalytics,
} from './playlist-analytics'; } from './playlist-analytics';
export type { PlaylistAnalyticsData, PlaylistAnalyticsProps } from './playlist-analytics'; export type { PlaylistAnalyticsData, PlaylistAnalyticsProps } from './playlist-analytics';

View file

@ -64,6 +64,7 @@ export const Default: Story = {
export const SingleSelection: Story = { export const SingleSelection: Story = {
name: 'Une playlist sélectionnée', name: 'Une playlist sélectionnée',
args: { args: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylists has entries
selectedPlaylists: [mockPlaylists[0]!], selectedPlaylists: [mockPlaylists[0]!],
onSelectionClear: () => {}, onSelectionClear: () => {},
}, },

View file

@ -90,6 +90,7 @@ export class PlaylistErrorBoundary extends Component<Props, State> {
* T0502: Create Playlist Error Handling Improvements * T0502: Create Playlist Error Handling Improvements
* Uses ErrorDisplay component for consistent error presentation * Uses ErrorDisplay component for consistent error presentation
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- private fallback component co-located with the error-boundary class; refactor would require duplicating its onReset contract
function ErrorFallback({ function ErrorFallback({
error, error,
onReset, onReset,

View file

@ -2,5 +2,6 @@
* Re-export from playlist-list module for backward compatibility. * Re-export from playlist-list module for backward compatibility.
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
export { PlaylistList, default } from './playlist-list'; export { PlaylistList, default } from './playlist-list';
export type { PlaylistListProps, SortField, SortOrder } from './playlist-list'; export type { PlaylistListProps, SortField, SortOrder } from './playlist-list';

View file

@ -199,12 +199,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content, description: notification.content,
options: { options: {
action: notification.link action: notification.link
? { ? (() => {
label: 'Voir', const link = notification.link;
onClick: () => { return {
safeNavigate(notification.link!); label: 'Voir',
}, onClick: () => {
} if (link) safeNavigate(link);
},
};
})()
: undefined, : undefined,
}, },
}; };
@ -215,12 +218,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content, description: notification.content,
options: { options: {
action: notification.link action: notification.link
? { ? (() => {
label: 'Voir', const link = notification.link;
onClick: () => { return {
safeNavigate(notification.link!); label: 'Voir',
}, onClick: () => {
} if (link) safeNavigate(link);
},
};
})()
: undefined, : undefined,
}, },
}; };
@ -231,12 +237,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content, description: notification.content,
options: { options: {
action: notification.link action: notification.link
? { ? (() => {
label: 'Voir', const link = notification.link;
onClick: () => { return {
safeNavigate(notification.link!); label: 'Voir',
}, onClick: () => {
} if (link) safeNavigate(link);
},
};
})()
: undefined, : undefined,
}, },
}; };
@ -247,12 +256,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content, description: notification.content,
options: { options: {
action: notification.link action: notification.link
? { ? (() => {
label: 'Voir', const link = notification.link;
onClick: () => { return {
safeNavigate(notification.link!); label: 'Voir',
}, onClick: () => {
} if (link) safeNavigate(link);
},
};
})()
: undefined, : undefined,
}, },
}; };

View file

@ -104,6 +104,7 @@ const defaultHookReturn = {
}, },
collaborators: [], collaborators: [],
tracks: [mockTrack], tracks: [mockTrack],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylist.tracks declared above
playlistTracks: mockPlaylist.tracks!, playlistTracks: mockPlaylist.tracks!,
isAddTrackModalOpen: false, isAddTrackModalOpen: false,
setIsAddTrackModalOpen: vi.fn(), setIsAddTrackModalOpen: vi.fn(),

View file

@ -9,7 +9,10 @@ import { getPresence } from '../services/presenceService';
export function usePresence(userId: string | null | undefined) { export function usePresence(userId: string | null | undefined) {
return useQuery({ return useQuery({
queryKey: ['presence', userId], queryKey: ['presence', userId],
queryFn: () => getPresence(userId!), queryFn: () => {
if (!userId) throw new Error('userId required');
return getPresence(userId);
},
enabled: !!userId, enabled: !!userId,
staleTime: 30_000, // 30s - presence doesn't change that often staleTime: 30_000, // 30s - presence doesn't change that often
}); });

View file

@ -153,11 +153,12 @@ describe('AvatarUpload', () => {
const dropZone = container.querySelector('.rounded-full'); const dropZone = container.querySelector('.rounded-full');
expect(dropZone).toBeInTheDocument(); expect(dropZone).toBeInTheDocument();
if (!dropZone) throw new Error('dropZone not found');
const validFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' }); const validFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const dataTransfer = { files: [validFile] }; const dataTransfer = { files: [validFile] };
fireEvent.drop(dropZone!, { fireEvent.drop(dropZone, {
dataTransfer, dataTransfer,
}); });

View file

@ -37,6 +37,7 @@ export function ProfileSocialLinksSection({ socialLinks }: ProfileSocialLinksSec
key, key,
label, label,
Icon, Icon,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by socialLinks?.[key] != null in filter above
href: String(socialLinks![key]), href: String(socialLinks![key]),
})); }));

View file

@ -39,7 +39,10 @@ export function useUserProfilePage() {
const { data: postsData, isLoading: isPostsLoading } = useQuery({ const { data: postsData, isLoading: isPostsLoading } = useQuery({
queryKey: ['userPosts', profile?.id], queryKey: ['userPosts', profile?.id],
queryFn: () => socialService.getPostsByUser(profile!.id, 1, profile ?? undefined), queryFn: () => {
if (!profile?.id) throw new Error('profile.id required');
return socialService.getPostsByUser(profile.id, 1, profile);
},
enabled: !!profile?.id, enabled: !!profile?.id,
retry: false, retry: false,
}); });
@ -53,7 +56,10 @@ export function useUserProfilePage() {
const { data: repostsData } = useQuery({ const { data: repostsData } = useQuery({
queryKey: ['userReposts', profile?.id], queryKey: ['userReposts', profile?.id],
queryFn: () => usersApi.getUserReposts(profile!.id, 20, 0), queryFn: () => {
if (!profile?.id) throw new Error('profile.id required');
return usersApi.getUserReposts(profile.id, 20, 0);
},
enabled: !!profile?.id, enabled: !!profile?.id,
retry: false, retry: false,
}); });

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <PurchasesViewSkeleton />, render: () => <PurchasesViewSkeleton />,
}; };

View file

@ -94,7 +94,9 @@ describe('AssignRoleModal', () => {
// Open the Select dropdown to see role options // Open the Select dropdown to see role options
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i }); const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!); const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument();
@ -121,7 +123,9 @@ describe('AssignRoleModal', () => {
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i }); const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!); const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
const option = await screen.findByRole('option', { name: /administrator/i }); const option = await screen.findByRole('option', { name: /administrator/i });
await user.click(option); await user.click(option);
@ -178,7 +182,9 @@ describe('AssignRoleModal', () => {
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i }); const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!); const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
const option = await screen.findByRole('option', { name: /administrator/i }); const option = await screen.findByRole('option', { name: /administrator/i });
await user.click(option); await user.click(option);

View file

@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
/** Loading state — skeleton to avoid layout shift. */ /** Loading state — skeleton to avoid layout shift. */
export const Loading: Story = { export const Loading: Story = {
name: 'Loading',
render: () => <SearchPageSkeleton />, render: () => <SearchPageSkeleton />,
}; };
/** Empty results (no tracks, artists, playlists). */ /** Empty results (no tracks, artists, playlists). */
export const Empty: Story = { export const Empty: Story = {
name: 'Empty',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [
@ -49,7 +47,6 @@ export const Empty: Story = {
/** Error loading search. */ /** Error loading search. */
export const Error: Story = { export const Error: Story = {
name: 'Error',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [

View file

@ -2,4 +2,5 @@
* Re-export from account-settings module for backward compatibility. * Re-export from account-settings module for backward compatibility.
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
export { AccountSettings, AccountSettingsSkeleton, default } from './account-settings'; export { AccountSettings, AccountSettingsSkeleton, default } from './account-settings';

View file

@ -19,7 +19,10 @@ export function PresenceInvisibleToggle() {
const { data: presence } = useQuery({ const { data: presence } = useQuery({
queryKey: ['presence', user?.id], queryKey: ['presence', user?.id],
queryFn: () => getPresence(user!.id), queryFn: () => {
if (!user?.id) throw new Error('user.id required');
return getPresence(user.id);
},
enabled: !!user?.id, enabled: !!user?.id,
}); });

View file

@ -17,13 +17,18 @@ export function ProfileVisibilityCard() {
const { data: profile, isLoading } = useQuery({ const { data: profile, isLoading } = useQuery({
queryKey: ['profile', user?.id], queryKey: ['profile', user?.id],
queryFn: () => usersApi.getProfile(user!.id), queryFn: () => {
if (!user?.id) throw new Error('user.id required');
return usersApi.getProfile(user.id);
},
enabled: !!user?.id, enabled: !!user?.id,
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (isPublic: boolean) => mutationFn: (isPublic: boolean) => {
usersApi.updateProfile(user!.id, { is_public: isPublic }), if (!user?.id) throw new Error('user.id required');
return usersApi.updateProfile(user.id, { is_public: isPublic });
},
onSuccess: (updatedProfile) => { onSuccess: (updatedProfile) => {
queryClient.setQueryData(['profile', user?.id], updatedProfile); queryClient.setQueryData(['profile', user?.id], updatedProfile);
queryClient.invalidateQueries({ queryKey: ['userProfile'] }); queryClient.invalidateQueries({ queryKey: ['userProfile'] });

View file

@ -136,7 +136,9 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
<button <button
type="button" type="button"
className="appearance-none bg-transparent border-0 p-0 text-left w-full bg-card p-4 rounded-xl flex items-center gap-4 shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] group cursor-pointer transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="appearance-none bg-transparent border-0 p-0 text-left w-full bg-card p-4 rounded-xl flex items-center gap-4 shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] group cursor-pointer transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() => onPlay(item.track!)} onClick={() => {
if (item.track) onPlay(item.track);
}}
> >
<div className="w-16 h-16 rounded-lg overflow-hidden relative"> <div className="w-16 h-16 rounded-lg overflow-hidden relative">
<img <img

View file

@ -49,7 +49,6 @@ export const Loading: Story = {
/** Données vides (aucun segment) */ /** Données vides (aucun segment) */
export const Empty: Story = { export const Empty: Story = {
name: 'Empty',
args: { args: {
trackId: '123', trackId: '123',
initialHeatmap: { initialHeatmap: {
@ -62,7 +61,6 @@ export const Empty: Story = {
/** Erreur chargement */ /** Erreur chargement */
export const Error: Story = { export const Error: Story = {
name: 'Error',
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [

View file

@ -36,16 +36,17 @@ export function getSkipBorderColor(skipCount: number, listenCount: number): stri
} }
function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null { function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
if (!heatmap.segments.length) return null; const first = heatmap.segments[0];
if (!first) return null;
const totalListens = heatmap.segments.reduce((sum, seg) => sum + seg.listen_count, 0); const totalListens = heatmap.segments.reduce((sum, seg) => sum + seg.listen_count, 0);
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0); const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
const maxIntensitySegment = heatmap.segments.reduce( const maxIntensitySegment = heatmap.segments.reduce(
(max, seg) => (seg.intensity > max.intensity ? seg : max), (max, seg) => (seg.intensity > max.intensity ? seg : max),
heatmap.segments[0]!, first,
); );
const maxSkipSegment = heatmap.segments.reduce( const maxSkipSegment = heatmap.segments.reduce(
(max, seg) => (seg.skip_count > max.skip_count ? seg : max), (max, seg) => (seg.skip_count > max.skip_count ? seg : max),
heatmap.segments[0]!, first,
); );
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment }; return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
} }

View file

@ -94,6 +94,7 @@ describe('useBitrateAdaptation', () => {
expect(result.current.isAdapting).toBe(true); expect(result.current.isAdapting).toBe(true);
await act(async () => { await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({ resolvePromise!({
recommended_bitrate: 320, recommended_bitrate: 320,
}); });
@ -217,6 +218,7 @@ describe('useBitrateAdaptation', () => {
// Resolve the promise after unmount // Resolve the promise after unmount
await act(async () => { await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({ resolvePromise!({
recommended_bitrate: 320, recommended_bitrate: 320,
}); });

View file

@ -184,6 +184,7 @@ describe('useHLSStream', () => {
unmount(); unmount();
// Resolve the promise after unmount // Resolve the promise after unmount
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({ resolvePromise!({
status: 'ready', status: 'ready',
bitrates: [128], bitrates: [128],

View file

@ -185,7 +185,7 @@ export function SubscriptionPage(): React.ReactElement {
}`} }`}
> >
{currentSub.status === 'trialing' {currentSub.status === 'trialing'
? `Trial (ends ${formatDate(currentSub.trial_end!)})` ? `Trial (ends ${formatDate(currentSub.trial_end ?? '')})`
: currentSub.status} : currentSub.status}
</span> </span>
{currentSub.cancel_at_period_end && ( {currentSub.cancel_at_period_end && (

View file

@ -95,9 +95,11 @@ export const DeeplyNested: Story = {
...mockComment, ...mockComment,
replies: [ replies: [
{ {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![0], ...mockCommentWithReplies.replies![0],
replies: [ replies: [
{ {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![1], ...mockCommentWithReplies.replies![1],
content: 'Nested reply', content: 'Nested reply',
id: 'r3' id: 'r3'
@ -182,10 +184,12 @@ export const VisualStressTest: Story = {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
replies: [ replies: [
{ {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![0], ...mockCommentWithReplies.replies![0],
content: 'Short reply.', content: 'Short reply.',
} as TrackComment, } as TrackComment,
{ {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![1], ...mockCommentWithReplies.replies![1],
content: content:
'A much longer reply that wraps multiple lines and tests tracking-tight, radius tokens, and hover states without breaking the thread layout or overflowing.', 'A much longer reply that wraps multiple lines and tests tracking-tight, radius tokens, and hover states without breaking the thread layout or overflowing.',

View file

@ -5,6 +5,7 @@
export { export {
TrackFilters, TrackFilters,
TrackFiltersSkeleton, TrackFiltersSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
default, default,
} from './track-filters'; } from './track-filters';
export type { TrackFiltersProps, TrackFilterOptions } from './track-filters'; export type { TrackFiltersProps, TrackFilterOptions } from './track-filters';

View file

@ -71,7 +71,9 @@ describe('TrackGrid', () => {
} else { } else {
// Si pas de role="button", chercher dans le conteneur parent // Si pas de role="button", chercher dans le conteneur parent
const track1Text = screen.getByText('Track 1'); const track1Text = screen.getByText('Track 1');
await user.click(track1Text.closest('div')!); const div = track1Text.closest('div');
if (!div) throw new Error('parent div not found');
await user.click(div);
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]); expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
} }
}); });

View file

@ -59,7 +59,8 @@ describe('TrackList', () => {
render(<TrackList tracks={mockTracks} onTrackClick={mockOnTrackClick} />); render(<TrackList tracks={mockTracks} onTrackClick={mockOnTrackClick} />);
const track1 = screen.getByText('Track 1').closest('[role="listitem"]'); const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.click(track1!); if (!track1) throw new Error('track1 listitem not found');
await user.click(track1);
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]); expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
}); });
@ -71,7 +72,8 @@ describe('TrackList', () => {
// Hover to show play button // Hover to show play button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]'); const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!); if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
@ -215,7 +217,8 @@ describe('TrackList', () => {
// Hover to show like button // Hover to show like button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]'); const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!); if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
@ -233,7 +236,8 @@ describe('TrackList', () => {
// Hover to show more button // Hover to show more button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]'); const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!); if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
@ -285,7 +289,8 @@ describe('TrackList', () => {
// Hover to show like button // Hover to show like button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]'); const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!); if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));

View file

@ -5,6 +5,7 @@
export { export {
TrackSearchFilters, TrackSearchFilters,
TrackSearchFiltersSkeleton, TrackSearchFiltersSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
default, default,
} from './track-search-filters'; } from './track-search-filters';
export type { export type {

View file

@ -103,7 +103,7 @@ export function TrackStemsSection({ track, isCreator }: TrackStemsSectionProps)
<p className="text-muted-foreground">{t('tracks.stemsSection.uploadHelp')}</p> <p className="text-muted-foreground">{t('tracks.stemsSection.uploadHelp')}</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{stems!.map((stem) => ( {(stems ?? []).map((stem) => (
<li <li
key={stem.id} key={stem.id}
className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50" className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50"

View file

@ -82,7 +82,9 @@ export function CommentThreadContent({
{onSeek ? ( {onSeek ? (
<button <button
type="button" type="button"
onClick={() => onSeek(comment.timestamp!)} onClick={() => {
if (comment.timestamp != null) onSeek(comment.timestamp);
}}
className="text-xs text-primary hover:underline font-medium tabular-nums" className="text-xs text-primary hover:underline font-medium tabular-nums"
aria-label={`Aller à ${formatTime(comment.timestamp)}`} aria-label={`Aller à ${formatTime(comment.timestamp)}`}
> >

View file

@ -26,7 +26,7 @@ export function useCommentReplies({
}); });
const replies = hasInitialReplies const replies = hasInitialReplies
? initialReplies! ? (initialReplies ?? [])
: (repliesData?.replies ?? []); : (repliesData?.replies ?? []);
const isLoadingReplies = const isLoadingReplies =

View file

@ -94,9 +94,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe // Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) { if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
act(() => { act(() => {
observerCallback!( cb(
[ [
{ {
isIntersecting: true, isIntersecting: true,
@ -140,9 +141,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe // Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) { if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
act(() => { act(() => {
observerCallback!( cb(
[ [
{ {
isIntersecting: true, isIntersecting: true,
@ -288,9 +290,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe // Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) { if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
act(() => { act(() => {
observerCallback!( cb(
[ [
{ {
isIntersecting: true, isIntersecting: true,
@ -336,10 +339,11 @@ describe('useInfiniteScroll', () => {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
if (observerCallback && mockObserverInstance) { if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
// Simuler plusieurs intersections rapides // Simuler plusieurs intersections rapides
act(() => { act(() => {
observerCallback!( cb(
[ [
{ {
isIntersecting: true, isIntersecting: true,
@ -355,7 +359,7 @@ describe('useInfiniteScroll', () => {
); );
// Simuler une deuxième intersection immédiatement // Simuler une deuxième intersection immédiatement
observerCallback!( cb(
[ [
{ {
isIntersecting: true, isIntersecting: true,

View file

@ -305,6 +305,7 @@ describe('useTrackList', () => {
expect(result.current.isLoading).toBe(true); expect(result.current.isLoading).toBe(true);
act(() => { act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({ resolvePromise!({
data: mockTracks, data: mockTracks,
total: mockTracks.length, total: mockTracks.length,

View file

@ -74,12 +74,14 @@ export function useTrackList(
const getInitialFilters = () => { const getInitialFilters = () => {
if (syncUrlParams) { if (syncUrlParams) {
const filters: TrackFilters = {}; const filters: TrackFilters = {};
if (searchParams.get('genre')) filters.genre = searchParams.get('genre')!; const genre = searchParams.get('genre');
if (searchParams.get('artist')) if (genre) filters.genre = genre;
filters.artist = searchParams.get('artist')!; const artist = searchParams.get('artist');
if (searchParams.get('album')) filters.album = searchParams.get('album')!; if (artist) filters.artist = artist;
if (searchParams.get('year')) const album = searchParams.get('album');
filters.year = parseInt(searchParams.get('year')!); if (album) filters.album = album;
const year = searchParams.get('year');
if (year) filters.year = parseInt(year);
// Basic mapping // Basic mapping
return { ...initialFilterOptions, ...filters }; return { ...initialFilterOptions, ...filters };
} }
@ -92,9 +94,10 @@ export function useTrackList(
const getInitialSort = () => { const getInitialSort = () => {
if (syncUrlParams) { if (syncUrlParams) {
if (searchParams.get('sortField')) { const sortField = searchParams.get('sortField');
if (sortField) {
return { return {
field: searchParams.get('sortField')!, field: sortField,
order: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc', order: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc',
}; };
} }

View file

@ -115,6 +115,10 @@ export class ChunkedUploadManager {
); );
this.state.uploadId = uploadId; this.state.uploadId = uploadId;
} }
const uploadId = this.state.uploadId;
if (!uploadId) {
throw new Error('uploadId must be set after initiateChunkedUpload');
}
// 2. Uploader chaque chunk séquentiellement // 2. Uploader chaque chunk séquentiellement
for (let i = this.currentChunkIndex; i < this.chunks.length; i++) { for (let i = this.currentChunkIndex; i < this.chunks.length; i++) {
@ -134,7 +138,7 @@ export class ChunkedUploadManager {
for (let retry = 0; retry <= this.MAX_RETRIES; retry++) { for (let retry = 0; retry <= this.MAX_RETRIES; retry++) {
try { try {
const response = await trackService.uploadChunk( const response = await trackService.uploadChunk(
this.state.uploadId!, uploadId,
chunkNumber, chunkNumber,
this.state.totalChunks, this.state.totalChunks,
this.file.size, this.file.size,
@ -184,9 +188,7 @@ export class ChunkedUploadManager {
// 4. Compléter l'upload // 4. Compléter l'upload
this.updateProgress(95); this.updateProgress(95);
const track = await trackService.completeChunkedUpload( const track = await trackService.completeChunkedUpload(uploadId);
this.state.uploadId!,
);
this.state.track = track; this.state.track = track;
this.state.isComplete = true; this.state.isComplete = true;

View file

@ -101,6 +101,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
// Build array of season start dates for this year and next // Build array of season start dates for this year and next
const starts = SEASONS.map((s, i) => { const starts = SEASONS.map((s, i) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS is a static array of 4 elements; modulo guarantees in-bounds
const nextSeason = SEASONS[(i + 1) % SEASONS.length]!; const nextSeason = SEASONS[(i + 1) % SEASONS.length]!;
return { return {
config: s, config: s,
@ -125,6 +126,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
} }
// Fallback: winter (SEASONS[3] is always 'winter') // Fallback: winter (SEASONS[3] is always 'winter')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS literal has 4 elements, index 3 always present
return { current: SEASONS[3]!, dayOfSeason: 1, progress: 0 }; return { current: SEASONS[3]!, dayOfSeason: 1, progress: 0 };
} }
@ -133,6 +135,7 @@ const TRANSITION_DAYS = 7;
function computeSeasonalContext(date: Date): SeasonalContext { function computeSeasonalContext(date: Date): SeasonalContext {
const { current, dayOfSeason, progress } = getSeasonForDate(date); const { current, dayOfSeason, progress } = getSeasonForDate(date);
const currentIndex = SEASONS.indexOf(current); const currentIndex = SEASONS.indexOf(current);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on 4-elem static SEASONS guarantees in-bounds
const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length]!; const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length]!;
const isTransitioning = dayOfSeason <= TRANSITION_DAYS; const isTransitioning = dayOfSeason <= TRANSITION_DAYS;
@ -170,6 +173,7 @@ export function useSeason(overrideSeason?: Season): SeasonalContext {
return useMemo(() => { return useMemo(() => {
if (overrideSeason) { if (overrideSeason) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS[0] exists in static 4-elem array
const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0]!; const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0]!;
return { return {
season: config.season, season: config.season,

View file

@ -142,6 +142,7 @@ function getPeriodForHour(hour: number): PeriodConfig {
if (hour >= p.startHour || hour < p.endHour) return p; if (hour >= p.startHour || hour < p.endHour) return p;
} }
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS literal has 6 entries, index 5 always present
return PERIODS[5]!; // fallback to night return PERIODS[5]!; // fallback to night
} }
@ -174,6 +175,7 @@ function computePalette(now: Date): TimeOfDayPalette {
// Find next period for smooth interpolation // Find next period for smooth interpolation
const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length; const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on static PERIODS array guarantees in-bounds
const next = PERIODS[nextPeriodIndex]!; const next = PERIODS[nextPeriodIndex]!;
// Smooth transition in the last 30% of each period // Smooth transition in the last 30% of each period
@ -228,6 +230,7 @@ export function useTimeOfDay(enabled = true): TimeOfDayPalette {
/** Force a specific period (for Storybook / testing) */ /** Force a specific period (for Storybook / testing) */
export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette { export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS[2] is midday, present in static array
const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2]!; const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2]!;
return { return {
period: config.period, period: config.period,

View file

@ -213,7 +213,9 @@ const waitForStylesheets = (): Promise<void> => {
import { ThemeProvider } from './components/theme/ThemeProvider'; import { ThemeProvider } from './components/theme/ThemeProvider';
const renderApp = () => { const renderApp = () => {
ReactDOM.createRoot(document.getElementById('root')!).render( const root = document.getElementById('root');
if (!root) throw new Error('root element not found in index.html');
ReactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">

View file

@ -56,6 +56,7 @@ export const AuthProvider = ({ children, loadingComponent }: AuthProviderProps)
* Hook to check if auth is currently initializing * Hook to check if auth is currently initializing
* Can be used by components that need to know auth initialization status * Can be used by components that need to know auth initialization status
*/ */
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the AuthProvider lifecycle
export const useAuthInitialization = () => { export const useAuthInitialization = () => {
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);

View file

@ -61,6 +61,7 @@ import { useUser } from '@/features/auth/hooks/useUser';
import type { RouteEntry } from './types'; import type { RouteEntry } from './types';
/** Redirects /profile to /u/<current_username> */ /** Redirects /profile to /u/<current_username> */
// eslint-disable-next-line react-refresh/only-export-components -- route config co-located with lazy components
function ProfileRedirect() { function ProfileRedirect() {
const { data: user } = useUser(); const { data: user } = useUser();
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -204,8 +204,11 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
// INT-TYPE-008: Return format aligned with backend LoginResponse // INT-TYPE-008: Return format aligned with backend LoginResponse
// Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean } // Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean }
if (!user) {
throw new Error('Login response missing user data');
}
return { return {
user: user!, user,
token: { token: {
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken || '', // Ensure string refresh_token: refreshToken || '', // Ensure string

View file

@ -100,9 +100,10 @@ export function createResponseSuccessHandler(apiClient: AxiosInstance) {
statusText: response.statusText, statusText: response.statusText,
headers: sanitizeForLogging(response.headers), headers: sanitizeForLogging(response.headers),
data: sanitizeForLogging(response.data), data: sanitizeForLogging(response.data),
duration: (response.config as AxiosResponse['config'] & { _requestStartTime?: number })?._requestStartTime duration: ((): number | undefined => {
? Date.now() - (response.config as AxiosResponse['config'] & { _requestStartTime?: number })._requestStartTime! const cfg = response.config as AxiosResponse['config'] & { _requestStartTime?: number };
: undefined, return cfg._requestStartTime ? Date.now() - cfg._requestStartTime : undefined;
})(),
}, },
); );
} }

View file

@ -97,7 +97,7 @@ async function searchWithFacets(
if (opts.cursor) params.cursor = opts.cursor; if (opts.cursor) params.cursor = opts.cursor;
if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50); if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50);
const f = opts.facets!; const f = opts.facets ?? {};
if (f.genre) params.genre = f.genre; if (f.genre) params.genre = f.genre;
if (f.musicalKey) params.musical_key = f.musicalKey; if (f.musicalKey) params.musical_key = f.musicalKey;
if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin; if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin;

View file

@ -423,8 +423,9 @@ describe('OfflineQueueService', () => {
// Check localStorage // Check localStorage
const stored = localStorageMock.getItem('veza_offline_queue'); const stored = localStorageMock.getItem('veza_offline_queue');
expect(stored).not.toBeNull(); expect(stored).not.toBeNull();
if (stored === null) throw new Error('stored is null');
const parsed = JSON.parse(stored!); const parsed = JSON.parse(stored);
expect(parsed.length).toBe(1); expect(parsed.length).toBe(1);
expect(parsed[0].config.url).toBe('/api/v1/tracks'); expect(parsed[0].config.url).toBe('/api/v1/tracks');
}); });
@ -485,7 +486,8 @@ describe('OfflineQueueService', () => {
// The service should filter out old requests on load // The service should filter out old requests on load
// Since we can't easily test singleton initialization, we'll verify the logic // Since we can't easily test singleton initialization, we'll verify the logic
const stored = localStorageMock.getItem('veza_offline_queue'); const stored = localStorageMock.getItem('veza_offline_queue');
const parsed = JSON.parse(stored!); if (stored === null) throw new Error('stored is null');
const parsed = JSON.parse(stored);
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const filtered = parsed.filter( const filtered = parsed.filter(
(req: QueuedRequest) => req.timestamp > oneDayAgo, (req: QueuedRequest) => req.timestamp > oneDayAgo,

View file

@ -53,14 +53,15 @@ class PWAService {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
try { try {
this.registration = await navigator.serviceWorker.register('/sw.js', { const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/', scope: '/',
}); });
this.registration = registration;
logger.info('[PWA] Service Worker registered successfully'); logger.info('[PWA] Service Worker registered successfully');
this.registration.addEventListener('updatefound', () => { registration.addEventListener('updatefound', () => {
const newWorker = this.registration!.installing; const newWorker = registration.installing;
if (newWorker) { if (newWorker) {
newWorker.addEventListener('statechange', () => { newWorker.addEventListener('statechange', () => {
if ( if (
@ -223,7 +224,8 @@ class PWAService {
* Clear all caches * Clear all caches
*/ */
public async clearCaches(): Promise<void> { public async clearCaches(): Promise<void> {
if (this.registration) { const registration = this.registration;
if (registration) {
const messageChannel = new MessageChannel(); const messageChannel = new MessageChannel();
return new Promise((resolve) => { return new Promise((resolve) => {
@ -233,7 +235,7 @@ class PWAService {
} }
}; };
this.registration!.active?.postMessage({ type: 'CLEAR_CACHE' }, [ registration.active?.postMessage({ type: 'CLEAR_CACHE' }, [
messageChannel.port2, messageChannel.port2,
]); ]);
}); });
@ -244,7 +246,9 @@ class PWAService {
* Get service worker version * Get service worker version
*/ */
public async getVersion(): Promise<string> { public async getVersion(): Promise<string> {
if (this.registration && this.registration.active) { const registration = this.registration;
const active = registration?.active;
if (registration && active) {
const messageChannel = new MessageChannel(); const messageChannel = new MessageChannel();
return new Promise((resolve) => { return new Promise((resolve) => {
@ -254,9 +258,7 @@ class PWAService {
} }
}; };
this.registration!.active!.postMessage({ type: 'GET_VERSION' }, [ active.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2]);
messageChannel.port2,
]);
}); });
} }

View file

@ -47,10 +47,13 @@ class RequestDeduplicationService {
// Sort params for consistent key generation // Sort params for consistent key generation
const params = config.params const params = config.params
? Object.keys(config.params) ? (() => {
.sort() const cp = config.params;
.map((key) => `${key}=${JSON.stringify(config.params![key])}`) return Object.keys(cp)
.join('&') .sort()
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
.join('&');
})()
: ''; : '';
// Serialize body for consistent key generation // Serialize body for consistent key generation

View file

@ -69,10 +69,13 @@ class ResponseCacheService {
// Sort params for consistent key generation // Sort params for consistent key generation
const params = config.params const params = config.params
? Object.keys(config.params) ? (() => {
.sort() const cp = config.params;
.map((key) => `${key}=${JSON.stringify(config.params![key])}`) return Object.keys(cp)
.join('&') .sort()
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
.join('&');
})()
: ''; : '';
// Include Authorization header in key for user-specific cache // Include Authorization header in key for user-specific cache

View file

@ -81,7 +81,8 @@ function decodeJWT(token: string): JWTPayload | null {
return null; return null;
} }
// Décoder le payload (base64url) // Décoder le payload (base64url)
const payload = parts[1]!; const payload = parts[1];
if (!payload) return null;
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded) as JWTPayload; return JSON.parse(decoded) as JWTPayload;
} catch (error) { } catch (error) {

View file

@ -17,6 +17,7 @@ const queryClient = new QueryClient({
}, },
}); });
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
function AllTheProviders({ children }: AllTheProvidersProps) { function AllTheProviders({ children }: AllTheProvidersProps) {
return ( return (
<BrowserRouter> <BrowserRouter>
@ -32,5 +33,6 @@ const customRender = (
) => render(ui, { wrapper: AllTheProviders, ...options }); ) => render(ui, { wrapper: AllTheProviders, ...options });
// Re-export everything // Re-export everything
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
export * from '@testing-library/react'; export * from '@testing-library/react';
export { customRender as render }; export { customRender as render };

View file

@ -28,6 +28,7 @@ interface AllProvidersProps {
initialEntries?: string[]; initialEntries?: string[];
} }
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
const AllProviders = ({ children, initialEntries }: AllProvidersProps) => { const AllProviders = ({ children, initialEntries }: AllProvidersProps) => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const Router = initialEntries ? MemoryRouter : BrowserRouter; const Router = initialEntries ? MemoryRouter : BrowserRouter;
@ -58,6 +59,7 @@ const customRender = (ui: ReactElement, options?: CustomRenderOptions) => {
}; };
// Re-export everything from @testing-library/react // Re-export everything from @testing-library/react
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
export * from '@testing-library/react'; export * from '@testing-library/react';
// Override render with our custom render // Override render with our custom render

View file

@ -77,21 +77,19 @@ export function parseRGB(rgbString: string): [number, number, number] | null {
// Handle CSS variable format: "255 255 255" // Handle CSS variable format: "255 255 255"
const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/); const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
if (spaceSeparated) { if (spaceSeparated) {
return [ const [, r, g, b] = spaceSeparated;
parseInt(spaceSeparated[1]!, 10), if (r && g && b) {
parseInt(spaceSeparated[2]!, 10), return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
parseInt(spaceSeparated[3]!, 10), }
];
} }
// Handle rgb() format: "rgb(255, 255, 255)" // Handle rgb() format: "rgb(255, 255, 255)"
const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) { if (rgbMatch) {
return [ const [, r, g, b] = rgbMatch;
parseInt(rgbMatch[1]!, 10), if (r && g && b) {
parseInt(rgbMatch[2]!, 10), return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
parseInt(rgbMatch[3]!, 10), }
];
} }
return null; return null;

View file

@ -121,7 +121,10 @@ export function sanitizeHTML(
content: string, content: string,
options: SanitizeOptions = {}, options: SanitizeOptions = {},
): string { ): string {
const config = { ...DEFAULT_OPTIONS, ...options }; const config: Required<SanitizeOptions> = {
...(DEFAULT_OPTIONS as Required<SanitizeOptions>),
...options,
};
let sanitized = content; let sanitized = content;
// Supprimer les patterns dangereux // Supprimer les patterns dangereux
@ -131,14 +134,14 @@ export function sanitizeHTML(
// Supprimer les tags non autorisés // Supprimer les tags non autorisés
if (config.stripUnknownTags) { if (config.stripUnknownTags) {
sanitized = stripUnknownTags(sanitized, config.allowedTags!); sanitized = stripUnknownTags(sanitized, config.allowedTags);
} }
// Nettoyer les attributs // Nettoyer les attributs
sanitized = sanitizeAttributes(sanitized, config.allowedAttributes!); sanitized = sanitizeAttributes(sanitized, config.allowedAttributes);
// Valider les URLs // Valider les URLs
sanitized = validateURLs(sanitized, config.allowedSchemes!); sanitized = validateURLs(sanitized, config.allowedSchemes);
// Supprimer les tags vides // Supprimer les tags vides
if (config.stripEmptyTags) { if (config.stripEmptyTags) {