Skip to content

Commit ff3d388

Browse files
feat(tests): add unit tests for Breadcrumb, Footer, LibraryPills, LoaderSpinner, NotFoundPage, tooltip, useCopyCode, useLayoutContext, and useUrlSync
- Update Node.js version in CI configuration - Implement tests for Breadcrumb component - Implement tests for Footer component - Implement tests for LibraryPills component - Implement tests for LoaderSpinner component - Implement tests for NotFoundPage component - Implement tests for tooltip utility functions - Implement tests for useCopyCode hook - Implement tests for useLayoutContext hook - Implement tests for useUrlSync hook
1 parent f7e73bf commit ff3d388

File tree

10 files changed

+528
-1
lines changed

10 files changed

+528
-1
lines changed

.github/workflows/ci-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ jobs:
185185
if: steps.check.outputs.should_test == 'true'
186186
uses: actions/setup-node@v6
187187
with:
188-
node-version: '20'
188+
node-version: '24'
189189
cache: 'yarn'
190190
cache-dependency-path: app/yarn.lock
191191

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '../test-utils';
3+
import { Breadcrumb } from './Breadcrumb';
4+
5+
describe('Breadcrumb', () => {
6+
it('renders breadcrumb items', () => {
7+
render(<Breadcrumb items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]} />);
8+
9+
expect(screen.getByText('pyplots.ai')).toBeInTheDocument();
10+
expect(screen.getByText('catalog')).toBeInTheDocument();
11+
});
12+
13+
it('renders linked items as links', () => {
14+
render(<Breadcrumb items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]} />);
15+
16+
const link = screen.getByText('pyplots.ai');
17+
expect(link.closest('a')).toHaveAttribute('href', '/');
18+
});
19+
20+
it('renders current page as plain text', () => {
21+
render(<Breadcrumb items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]} />);
22+
23+
const current = screen.getByText('catalog');
24+
expect(current.closest('a')).toBeNull();
25+
});
26+
27+
it('renders separator between items', () => {
28+
render(<Breadcrumb items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]} />);
29+
30+
expect(screen.getByText('›')).toBeInTheDocument();
31+
});
32+
33+
it('renders right action when provided', () => {
34+
render(
35+
<Breadcrumb
36+
items={[{ label: 'pyplots.ai', to: '/' }]}
37+
rightAction={<span>action</span>}
38+
/>
39+
);
40+
41+
expect(screen.getByText('action')).toBeInTheDocument();
42+
});
43+
44+
it('has navigation aria-label', () => {
45+
render(<Breadcrumb items={[{ label: 'home', to: '/' }]} />);
46+
47+
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'breadcrumb');
48+
});
49+
});

app/src/components/Footer.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, userEvent } from '../test-utils';
3+
import { Footer } from './Footer';
4+
5+
describe('Footer', () => {
6+
it('renders footer links', () => {
7+
render(<Footer />);
8+
9+
expect(screen.getByText('github')).toBeInTheDocument();
10+
expect(screen.getByText('stats')).toBeInTheDocument();
11+
expect(screen.getByText('legal')).toBeInTheDocument();
12+
expect(screen.getByText('mcp')).toBeInTheDocument();
13+
});
14+
15+
it('renders markus neusinger link', () => {
16+
render(<Footer />);
17+
18+
expect(screen.getByText('markus neusinger')).toBeInTheDocument();
19+
});
20+
21+
it('calls onTrackEvent when clicking github link', async () => {
22+
const onTrackEvent = vi.fn();
23+
const user = userEvent.setup();
24+
25+
render(<Footer onTrackEvent={onTrackEvent} />);
26+
27+
await user.click(screen.getByText('github'));
28+
expect(onTrackEvent).toHaveBeenCalledWith('external_link', expect.objectContaining({ destination: 'github' }));
29+
});
30+
31+
it('calls onTrackEvent when clicking stats link', async () => {
32+
const onTrackEvent = vi.fn();
33+
const user = userEvent.setup();
34+
35+
render(<Footer onTrackEvent={onTrackEvent} />);
36+
37+
await user.click(screen.getByText('stats'));
38+
expect(onTrackEvent).toHaveBeenCalledWith('external_link', expect.objectContaining({ destination: 'stats' }));
39+
});
40+
41+
it('renders without onTrackEvent', () => {
42+
render(<Footer />);
43+
expect(screen.getByText('github')).toBeInTheDocument();
44+
});
45+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, userEvent } from '../test-utils';
3+
import { LibraryPills } from './LibraryPills';
4+
5+
const mockImplementations = [
6+
{ library_id: 'matplotlib', library_name: 'Matplotlib', quality_score: 85 },
7+
{ library_id: 'seaborn', library_name: 'Seaborn', quality_score: 90 },
8+
{ library_id: 'plotly', library_name: 'Plotly', quality_score: 78 },
9+
];
10+
11+
describe('LibraryPills', () => {
12+
it('renders nothing for empty implementations', () => {
13+
const { container } = render(
14+
<LibraryPills implementations={[]} selectedLibrary="matplotlib" onSelect={vi.fn()} />
15+
);
16+
expect(container.firstChild).toBeNull();
17+
});
18+
19+
it('renders selected library', () => {
20+
render(
21+
<LibraryPills
22+
implementations={mockImplementations}
23+
selectedLibrary="matplotlib"
24+
onSelect={vi.fn()}
25+
/>
26+
);
27+
28+
expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0);
29+
});
30+
31+
it('calls onSelect when clicking a pill', async () => {
32+
const onSelect = vi.fn();
33+
const user = userEvent.setup();
34+
35+
render(
36+
<LibraryPills
37+
implementations={mockImplementations}
38+
selectedLibrary="matplotlib"
39+
onSelect={onSelect}
40+
/>
41+
);
42+
43+
// Click the next arrow to navigate
44+
const buttons = screen.getAllByRole('button');
45+
await user.click(buttons[1]); // right arrow
46+
47+
expect(onSelect).toHaveBeenCalled();
48+
});
49+
50+
it('calls onSelect when clicking prev arrow', async () => {
51+
const onSelect = vi.fn();
52+
const user = userEvent.setup();
53+
54+
render(
55+
<LibraryPills
56+
implementations={mockImplementations}
57+
selectedLibrary="matplotlib"
58+
onSelect={onSelect}
59+
/>
60+
);
61+
62+
const buttons = screen.getAllByRole('button');
63+
await user.click(buttons[0]); // left arrow
64+
65+
expect(onSelect).toHaveBeenCalled();
66+
});
67+
68+
it('handles single implementation', () => {
69+
render(
70+
<LibraryPills
71+
implementations={[mockImplementations[0]]}
72+
selectedLibrary="matplotlib"
73+
onSelect={vi.fn()}
74+
/>
75+
);
76+
77+
expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0);
78+
});
79+
80+
it('handles two implementations', () => {
81+
render(
82+
<LibraryPills
83+
implementations={mockImplementations.slice(0, 2)}
84+
selectedLibrary="matplotlib"
85+
onSelect={vi.fn()}
86+
/>
87+
);
88+
89+
expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0);
90+
});
91+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from '../test-utils';
3+
import { LoaderSpinner } from './LoaderSpinner';
4+
5+
describe('LoaderSpinner', () => {
6+
it('renders without crashing', () => {
7+
const { container } = render(<LoaderSpinner />);
8+
expect(container.firstChild).toBeInTheDocument();
9+
});
10+
11+
it('renders with small size', () => {
12+
const { container } = render(<LoaderSpinner size="small" />);
13+
expect(container.firstChild).toBeInTheDocument();
14+
});
15+
16+
it('renders with large size by default', () => {
17+
const { container } = render(<LoaderSpinner />);
18+
expect(container.firstChild).toBeInTheDocument();
19+
});
20+
});

app/src/hooks/useCopyCode.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useCopyCode } from './useCopyCode';
4+
5+
describe('useCopyCode', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
Object.assign(navigator, {
9+
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
10+
});
11+
});
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
});
16+
17+
it('starts with copied = false', () => {
18+
const { result } = renderHook(() => useCopyCode());
19+
expect(result.current.copied).toBe(false);
20+
});
21+
22+
it('copies text to clipboard', async () => {
23+
const { result } = renderHook(() => useCopyCode());
24+
25+
await act(async () => {
26+
await result.current.copyToClipboard('hello');
27+
});
28+
29+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
30+
expect(result.current.copied).toBe(true);
31+
});
32+
33+
it('resets copied after timeout', async () => {
34+
const { result } = renderHook(() => useCopyCode({ timeout: 1000 }));
35+
36+
await act(async () => {
37+
await result.current.copyToClipboard('hello');
38+
});
39+
expect(result.current.copied).toBe(true);
40+
41+
act(() => {
42+
vi.advanceTimersByTime(1000);
43+
});
44+
expect(result.current.copied).toBe(false);
45+
});
46+
47+
it('calls onCopy callback', async () => {
48+
const onCopy = vi.fn();
49+
const { result } = renderHook(() => useCopyCode({ onCopy }));
50+
51+
await act(async () => {
52+
await result.current.copyToClipboard('hello');
53+
});
54+
55+
expect(onCopy).toHaveBeenCalledOnce();
56+
});
57+
58+
it('reset sets copied to false', async () => {
59+
const { result } = renderHook(() => useCopyCode());
60+
61+
await act(async () => {
62+
await result.current.copyToClipboard('hello');
63+
});
64+
expect(result.current.copied).toBe(true);
65+
66+
act(() => {
67+
result.current.reset();
68+
});
69+
expect(result.current.copied).toBe(false);
70+
});
71+
72+
it('handles clipboard failure gracefully', async () => {
73+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
74+
(navigator.clipboard.writeText as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('denied'));
75+
76+
const { result } = renderHook(() => useCopyCode());
77+
78+
await act(async () => {
79+
await result.current.copyToClipboard('hello');
80+
});
81+
82+
expect(result.current.copied).toBe(false);
83+
expect(consoleSpy).toHaveBeenCalled();
84+
consoleSpy.mockRestore();
85+
});
86+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import { useAppData, useHomeState, initialHomeState } from './useLayoutContext';
4+
5+
describe('useAppData', () => {
6+
it('throws when used outside provider', () => {
7+
expect(() => {
8+
renderHook(() => useAppData());
9+
}).toThrow('useAppData must be used within AppDataProvider');
10+
});
11+
});
12+
13+
describe('useHomeState', () => {
14+
it('throws when used outside provider', () => {
15+
expect(() => {
16+
renderHook(() => useHomeState());
17+
}).toThrow('useHomeState must be used within AppDataProvider');
18+
});
19+
});
20+
21+
describe('initialHomeState', () => {
22+
it('has correct default values', () => {
23+
expect(initialHomeState).toEqual({
24+
allImages: [],
25+
displayedImages: [],
26+
activeFilters: [],
27+
filterCounts: null,
28+
globalCounts: null,
29+
orCounts: [],
30+
hasMore: false,
31+
scrollY: 0,
32+
initialized: false,
33+
});
34+
});
35+
});

0 commit comments

Comments
 (0)