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:
parent
45bdf060ca
commit
34e9d69af3
10 changed files with 312 additions and 13 deletions
39
apps/web/src/components/ui/AnimatedNumber.test.tsx
Normal file
39
apps/web/src/components/ui/AnimatedNumber.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
43
apps/web/src/components/ui/FAB.test.tsx
Normal file
43
apps/web/src/components/ui/FAB.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
91
apps/web/src/components/ui/accordion/Accordion.test.tsx
Normal file
91
apps/web/src/components/ui/accordion/Accordion.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
65
apps/web/src/components/ui/collapsible.test.tsx
Normal file
65
apps/web/src/components/ui/collapsible.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
43
apps/web/src/components/ui/floating-input.test.tsx
Normal file
43
apps/web/src/components/ui/floating-input.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue