diff --git a/apps/web/src/components/ui/AnimatedNumber.test.tsx b/apps/web/src/components/ui/AnimatedNumber.test.tsx new file mode 100644 index 000000000..95f59cc7c --- /dev/null +++ b/apps/web/src/components/ui/AnimatedNumber.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// Mock the animated counter hook to return the end value immediately +vi.mock('@/hooks/useAnimatedCounter', () => ({ + useAnimatedCounter: ({ end }: { end: number }) => end, +})); + +import { AnimatedNumber } from './AnimatedNumber'; + +describe('AnimatedNumber', () => { + it('renders the value', () => { + render(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + const span = container.querySelector('span'); + expect(span?.className).toContain('text-lg'); + expect(span?.className).toContain('tabular-nums'); + }); + + it('uses format function when provided', () => { + render( + `$${n}`} /> + ); + expect(screen.getByText('$1500')).toBeInTheDocument(); + }); + + it('formats large numbers with locale string', () => { + render(); + // toLocaleString will format 1000 — the exact output depends on locale + const span = screen.getByText((content) => content.includes('1')); + expect(span).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ui/FAB.test.tsx b/apps/web/src/components/ui/FAB.test.tsx new file mode 100644 index 000000000..c09102117 --- /dev/null +++ b/apps/web/src/components/ui/FAB.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FAB } from './FAB'; + +describe('FAB', () => { + it('renders children', () => { + render(+); + expect(screen.getByText('+')).toBeInTheDocument(); + }); + + it('has default aria-label "Action"', () => { + render(+); + expect(screen.getByLabelText('Action')).toBeInTheDocument(); + }); + + it('uses custom label as aria-label', () => { + render(+); + expect(screen.getByLabelText('Upload Track')).toBeInTheDocument(); + }); + + it('shows label text when showLabel is true', () => { + render(+); + expect(screen.getByText('Upload Track')).toBeInTheDocument(); + }); + + it('does not show label text when showLabel is false', () => { + render(+); + expect(screen.queryByText('Upload Track')).not.toBeInTheDocument(); + }); + + it('handles click events', () => { + const onClick = vi.fn(); + render(+); + fireEvent.click(screen.getByLabelText('Action')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders as a fixed element', () => { + const { container } = render(+); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.className).toContain('fixed'); + }); +}); diff --git a/apps/web/src/components/ui/accordion/Accordion.test.tsx b/apps/web/src/components/ui/accordion/Accordion.test.tsx new file mode 100644 index 000000000..790a69b85 --- /dev/null +++ b/apps/web/src/components/ui/accordion/Accordion.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Accordion } from './Accordion'; +import { AccordionItem } from './AccordionItem'; +import { AccordionTrigger } from './AccordionTrigger'; +import { AccordionContent } from './AccordionContent'; + +describe('Accordion', () => { + it('renders accordion items', () => { + render( + + + Section 1 + Content 1 + + + Section 2 + Content 2 + + + ); + expect(screen.getByText('Section 1')).toBeInTheDocument(); + expect(screen.getByText('Section 2')).toBeInTheDocument(); + }); + + it('toggles item open/close in single mode', () => { + render( + + + Section 1 + Content 1 + + + ); + const trigger = screen.getByText('Section 1'); + expect(trigger.closest('button')).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(trigger.closest('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('calls onValueChange when toggled', () => { + const onValueChange = vi.fn(); + render( + + + Section 1 + Content 1 + + + ); + fireEvent.click(screen.getByText('Section 1')); + expect(onValueChange).toHaveBeenCalledWith('item-1'); + }); + + it('supports multiple mode', () => { + const onValueChange = vi.fn(); + render( + + + Section 1 + Content 1 + + + Section 2 + Content 2 + + + ); + fireEvent.click(screen.getByText('Section 1')); + expect(onValueChange).toHaveBeenCalledWith(['item-1']); + }); + + it('opens with defaultValue', () => { + render( + + + Section 1 + Content 1 + + + Section 2 + Content 2 + + + ); + const trigger1 = screen.getByText('Section 1').closest('button'); + const trigger2 = screen.getByText('Section 2').closest('button'); + expect(trigger1).toHaveAttribute('aria-expanded', 'false'); + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/apps/web/src/components/ui/collapsible.test.tsx b/apps/web/src/components/ui/collapsible.test.tsx new file mode 100644 index 000000000..338cd5489 --- /dev/null +++ b/apps/web/src/components/ui/collapsible.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Collapsible } from './collapsible'; + +describe('Collapsible', () => { + it('renders trigger content', () => { + render( + Click me}> +

Hidden content

+
+ ); + expect(screen.getByText('Click me')).toBeInTheDocument(); + }); + + it('starts collapsed by default', () => { + render( + Toggle}> +

Content

+
+ ); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles on click', () => { + render( + Toggle}> +

Content

+
+ ); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('calls onOpenChange when toggled', () => { + const onOpenChange = vi.fn(); + render( + Toggle} onOpenChange={onOpenChange}> +

Content

+
+ ); + fireEvent.click(screen.getByRole('button')); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('respects defaultOpen prop', () => { + render( + Toggle} defaultOpen> +

Content

+
+ ); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('hides chevron when showChevron is false', () => { + const { container } = render( + Toggle} showChevron={false}> +

Content

+
+ ); + // No SVG icon should be rendered + expect(container.querySelector('svg')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/floating-input.test.tsx b/apps/web/src/components/ui/floating-input.test.tsx new file mode 100644 index 000000000..2145a1451 --- /dev/null +++ b/apps/web/src/components/ui/floating-input.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FloatingInput } from './floating-input'; + +describe('FloatingInput', () => { + it('renders with label', () => { + render(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + }); + + it('displays error message', () => { + render(); + expect(screen.getByText('Invalid email')).toBeInTheDocument(); + }); + + it('renders icon when provided', () => { + render( + @} + /> + ); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('toggles password visibility', () => { + render( + + ); + const input = screen.getByLabelText('Password'); + expect(input).toHaveAttribute('type', 'password'); + + const toggleButton = screen.getByLabelText('Show password'); + fireEvent.click(toggleButton); + expect(input).toHaveAttribute('type', 'text'); + }); + + it('applies custom className', () => { + render(); + const input = screen.getByLabelText('Test'); + expect(input.className).toContain('custom-class'); + }); +}); diff --git a/apps/web/src/features/chat/components/ChatRoom.tsx b/apps/web/src/features/chat/components/ChatRoom.tsx index bfcacff47..451b3f9ad 100644 --- a/apps/web/src/features/chat/components/ChatRoom.tsx +++ b/apps/web/src/features/chat/components/ChatRoom.tsx @@ -27,6 +27,16 @@ export const ChatRoom: React.FC = ({ conversationId }) => { const [highlightedMessageId, setHighlightedMessageId] = useState< string | null >(null); + const highlightTimeoutRef = useRef | null>(null); + + // Cleanup highlight timeout on unmount + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + }; + }, []); const currentMessages = messages[conversationId] || []; const fetchingRef = useRef<{ [key: string]: boolean }>({}); @@ -55,7 +65,10 @@ export const ChatRoom: React.FC = ({ conversationId }) => { const messageElement = document.getElementById(`message-${messageId}`); if (messageElement) { messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - setTimeout(() => setHighlightedMessageId(null), 3000); + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + highlightTimeoutRef.current = setTimeout(() => setHighlightedMessageId(null), 3000); } }; diff --git a/apps/web/src/features/tracks/components/TrackList.test.tsx b/apps/web/src/features/tracks/components/TrackList.test.tsx index f8f2d1340..504a975cf 100644 --- a/apps/web/src/features/tracks/components/TrackList.test.tsx +++ b/apps/web/src/features/tracks/components/TrackList.test.tsx @@ -197,9 +197,9 @@ describe('TrackList', () => { />, ); const selectedRow = container.querySelector( - '.bg-blue-50, .dark:bg-blue-900/20', + '[class*="bg-primary"]', ); - expect(selectedRow).toBeInTheDocument(); + expect(selectedRow).toBeTruthy(); }); it('should call onTrackLike when like button is clicked', async () => { diff --git a/apps/web/src/mocks/handlers.ts b/apps/web/src/mocks/handlers.ts index e45a6d088..b88141619 100644 --- a/apps/web/src/mocks/handlers.ts +++ b/apps/web/src/mocks/handlers.ts @@ -1580,7 +1580,7 @@ export const handlers = [ http.post('*/api/v1/chat/token', () => { return HttpResponse.json({ success: true, - token: 'mock-chat-token' + data: { token: 'mock-chat-token' } }); }), @@ -1627,11 +1627,11 @@ export const handlers = [ }), http.post('*/api/v1/notifications/:id/read', () => { - return HttpResponse.json({ success: true }); + return HttpResponse.json({ success: true, data: { read: true } }); }), http.post('*/api/v1/notifications/read-all', () => { - return HttpResponse.json({ success: true }); + return HttpResponse.json({ success: true, data: { read: true } }); }), // Inventory / Gear (STORYBOOK_CONTRACT: mock for gear view stories) diff --git a/apps/web/src/services/socialService.ts b/apps/web/src/services/socialService.ts index 549693d1a..4a88f2d24 100644 --- a/apps/web/src/services/socialService.ts +++ b/apps/web/src/services/socialService.ts @@ -121,6 +121,7 @@ export const socialService = { deleteComment: async (id: string) => { await apiClient.delete(`/comments/${id}`); + return { success: true }; }, getNotifications: async () => { @@ -129,11 +130,13 @@ export const socialService = { }, markRead: async (id: string) => { - await apiClient.post(`/notifications/${id}/read`); + const response = await apiClient.post(`/notifications/${id}/read`); + return response.data; }, markAllRead: async () => { - await apiClient.post('/notifications/read-all'); + const response = await apiClient.post('/notifications/read-all'); + return response.data; }, getWebhooks: async () => { diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 7730d8468..ed1dbf81e 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -18,11 +18,13 @@ export default defineConfig({ exclude: [ ...configDefaults.exclude, '**/e2e/**', // Playwright E2E tests (run via playwright test) - '**/usePlaylistKeyboardShortcuts.test.ts', // Hook non implémenté (T0507) - '**/PlaylistVersionHistory.test.tsx', // Composant non implémenté (T0509) - '**/ShareLinkButton.test.tsx', // Composant non implémenté (T0488) - '**/PlaylistAccessibility.test.tsx', // Nécessite jest-axe (T0503) - '**/useRoutePreload-additional.test.ts', // Incompatibilité fake timers + renderHook + // The following tests are excluded because their underlying components/hooks + // are not yet implemented. They are tracked as future work items: + '**/usePlaylistKeyboardShortcuts.test.ts', // TODO(T0507): Implement keyboard shortcuts hook + '**/PlaylistVersionHistory.test.tsx', // TODO(T0509): Implement version history component + '**/ShareLinkButton.test.tsx', // TODO(T0488): Implement share link button component + '**/PlaylistAccessibility.test.tsx', // TODO(T0503): Add vitest-axe dependency for a11y testing + '**/useRoutePreload-additional.test.ts', // TODO: Fix fake timers + renderHook incompatibility ], coverage: { provider: 'v8',