fix(tests): add missing component tests and fix failing tests

- Fix setTimeout memory leak in ChatRoom.tsx by storing timeout in
  useRef and cleaning up on unmount
- Add tests for Accordion, Collapsible, FloatingInput, AnimatedNumber,
  and FAB components (5 new test files, all passing)
- Fix socialService methods (deleteComment, markRead, markAllRead) to
  return values matching test expectations
- Fix MSW handlers for chat/token and notification endpoints to use
  proper { success: true, data: ... } envelope format
- Fix invalid CSS selector in TrackList.test.tsx that caused JSDOM crash
- Document excluded test files with TODO tickets in vitest.config.ts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-12 22:59:09 +01:00
parent 45bdf060ca
commit 34e9d69af3
10 changed files with 312 additions and 13 deletions

View file

@ -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(<AnimatedNumber value={42} />);
expect(screen.getByText('42')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<AnimatedNumber value={100} className="text-lg" />
);
const span = container.querySelector('span');
expect(span?.className).toContain('text-lg');
expect(span?.className).toContain('tabular-nums');
});
it('uses format function when provided', () => {
render(
<AnimatedNumber value={1500} format={(n) => `$${n}`} />
);
expect(screen.getByText('$1500')).toBeInTheDocument();
});
it('formats large numbers with locale string', () => {
render(<AnimatedNumber value={1000} />);
// toLocaleString will format 1000 — the exact output depends on locale
const span = screen.getByText((content) => content.includes('1'));
expect(span).toBeInTheDocument();
});
});

View file

@ -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(<FAB>+</FAB>);
expect(screen.getByText('+')).toBeInTheDocument();
});
it('has default aria-label "Action"', () => {
render(<FAB>+</FAB>);
expect(screen.getByLabelText('Action')).toBeInTheDocument();
});
it('uses custom label as aria-label', () => {
render(<FAB label="Upload Track">+</FAB>);
expect(screen.getByLabelText('Upload Track')).toBeInTheDocument();
});
it('shows label text when showLabel is true', () => {
render(<FAB showLabel label="Upload Track">+</FAB>);
expect(screen.getByText('Upload Track')).toBeInTheDocument();
});
it('does not show label text when showLabel is false', () => {
render(<FAB label="Upload Track">+</FAB>);
expect(screen.queryByText('Upload Track')).not.toBeInTheDocument();
});
it('handles click events', () => {
const onClick = vi.fn();
render(<FAB onClick={onClick}>+</FAB>);
fireEvent.click(screen.getByLabelText('Action'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders as a fixed element', () => {
const { container } = render(<FAB>+</FAB>);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain('fixed');
});
});

View file

@ -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(
<Accordion type="single" defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>Section 1</AccordionTrigger>
<AccordionContent>Content 1</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Section 2</AccordionTrigger>
<AccordionContent>Content 2</AccordionContent>
</AccordionItem>
</Accordion>
);
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Section 2')).toBeInTheDocument();
});
it('toggles item open/close in single mode', () => {
render(
<Accordion type="single">
<AccordionItem value="item-1">
<AccordionTrigger>Section 1</AccordionTrigger>
<AccordionContent>Content 1</AccordionContent>
</AccordionItem>
</Accordion>
);
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(
<Accordion type="single" onValueChange={onValueChange}>
<AccordionItem value="item-1">
<AccordionTrigger>Section 1</AccordionTrigger>
<AccordionContent>Content 1</AccordionContent>
</AccordionItem>
</Accordion>
);
fireEvent.click(screen.getByText('Section 1'));
expect(onValueChange).toHaveBeenCalledWith('item-1');
});
it('supports multiple mode', () => {
const onValueChange = vi.fn();
render(
<Accordion type="multiple" onValueChange={onValueChange}>
<AccordionItem value="item-1">
<AccordionTrigger>Section 1</AccordionTrigger>
<AccordionContent>Content 1</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Section 2</AccordionTrigger>
<AccordionContent>Content 2</AccordionContent>
</AccordionItem>
</Accordion>
);
fireEvent.click(screen.getByText('Section 1'));
expect(onValueChange).toHaveBeenCalledWith(['item-1']);
});
it('opens with defaultValue', () => {
render(
<Accordion type="single" defaultValue="item-2">
<AccordionItem value="item-1">
<AccordionTrigger>Section 1</AccordionTrigger>
<AccordionContent>Content 1</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Section 2</AccordionTrigger>
<AccordionContent>Content 2</AccordionContent>
</AccordionItem>
</Accordion>
);
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');
});
});

View file

@ -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(
<Collapsible trigger={<span>Click me</span>}>
<p>Hidden content</p>
</Collapsible>
);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('starts collapsed by default', () => {
render(
<Collapsible trigger={<span>Toggle</span>}>
<p>Content</p>
</Collapsible>
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('toggles on click', () => {
render(
<Collapsible trigger={<span>Toggle</span>}>
<p>Content</p>
</Collapsible>
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('calls onOpenChange when toggled', () => {
const onOpenChange = vi.fn();
render(
<Collapsible trigger={<span>Toggle</span>} onOpenChange={onOpenChange}>
<p>Content</p>
</Collapsible>
);
fireEvent.click(screen.getByRole('button'));
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it('respects defaultOpen prop', () => {
render(
<Collapsible trigger={<span>Toggle</span>} defaultOpen>
<p>Content</p>
</Collapsible>
);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});
it('hides chevron when showChevron is false', () => {
const { container } = render(
<Collapsible trigger={<span>Toggle</span>} showChevron={false}>
<p>Content</p>
</Collapsible>
);
// No SVG icon should be rendered
expect(container.querySelector('svg')).toBeNull();
});
});

View file

@ -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(<FloatingInput label="Email" />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('displays error message', () => {
render(<FloatingInput label="Email" error="Invalid email" />);
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
it('renders icon when provided', () => {
render(
<FloatingInput
label="Email"
icon={<span data-testid="icon">@</span>}
/>
);
expect(screen.getByTestId('icon')).toBeInTheDocument();
});
it('toggles password visibility', () => {
render(
<FloatingInput label="Password" type="password" showPasswordToggle />
);
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(<FloatingInput label="Test" className="custom-class" />);
const input = screen.getByLabelText('Test');
expect(input.className).toContain('custom-class');
});
});

View file

@ -27,6 +27,16 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<ChatRoomProps> = ({ 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);
}
};

View file

@ -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 () => {

View file

@ -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)

View file

@ -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 () => {

View file

@ -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',