Skip to content

Commit 7028199

Browse files
feat(tests): add unit tests for Header, LegalPage, McpPage, and ToolbarActions components
- Implement tests for rendering and interaction in Header - Add tests for LegalPage section headings and links - Create tests for McpPage content and navigation - Include tests for CatalogLink and GridSizeToggle in ToolbarActions
1 parent ff3d388 commit 7028199

File tree

9 files changed

+1062
-0
lines changed

9 files changed

+1062
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { reportWebVitals } from './reportWebVitals';
3+
4+
describe('reportWebVitals', () => {
5+
const originalLocation = window.location;
6+
7+
afterEach(() => {
8+
Object.defineProperty(window, 'location', {
9+
value: originalLocation,
10+
writable: true,
11+
configurable: true,
12+
});
13+
delete window.plausible;
14+
});
15+
16+
it('does nothing in non-production environment', () => {
17+
Object.defineProperty(window, 'location', {
18+
value: { ...originalLocation, hostname: 'localhost' },
19+
writable: true,
20+
configurable: true,
21+
});
22+
23+
// Should return early without importing web-vitals
24+
reportWebVitals();
25+
// No error = success, web-vitals is not imported
26+
});
27+
28+
it('does nothing when hostname is not pyplots.ai', () => {
29+
Object.defineProperty(window, 'location', {
30+
value: { ...originalLocation, hostname: 'staging.pyplots.ai' },
31+
writable: true,
32+
configurable: true,
33+
});
34+
35+
reportWebVitals();
36+
// No error = success
37+
});
38+
39+
it('attempts to load web-vitals in production', async () => {
40+
Object.defineProperty(window, 'location', {
41+
value: { ...originalLocation, hostname: 'pyplots.ai' },
42+
writable: true,
43+
configurable: true,
44+
});
45+
window.plausible = vi.fn();
46+
47+
// Mock the dynamic import
48+
const mockOnLCP = vi.fn();
49+
const mockOnCLS = vi.fn();
50+
const mockOnINP = vi.fn();
51+
52+
vi.mock('web-vitals', () => ({
53+
onLCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 2500, rating: 'good' }),
54+
onCLS: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 0.15, rating: 'needs-improvement' }),
55+
onINP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 200, rating: 'good' }),
56+
}));
57+
58+
reportWebVitals();
59+
60+
// Wait for dynamic import to resolve
61+
await vi.dynamicImportSettled();
62+
63+
expect(window.plausible).toHaveBeenCalledWith('LCP', {
64+
props: { value: '2500', rating: 'good' },
65+
});
66+
expect(window.plausible).toHaveBeenCalledWith('CLS', {
67+
props: { value: '0.15', rating: 'needs-improvement' },
68+
});
69+
expect(window.plausible).toHaveBeenCalledWith('INP', {
70+
props: { value: '200', rating: 'good' },
71+
});
72+
73+
vi.restoreAllMocks();
74+
});
75+
});

app/src/components/Footer.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,71 @@ describe('Footer', () => {
4242
render(<Footer />);
4343
expect(screen.getByText('github')).toBeInTheDocument();
4444
});
45+
46+
it('calls onTrackEvent when clicking linkedin link', async () => {
47+
const onTrackEvent = vi.fn();
48+
const user = userEvent.setup();
49+
50+
render(<Footer onTrackEvent={onTrackEvent} />);
51+
52+
await user.click(screen.getByText('markus neusinger'));
53+
expect(onTrackEvent).toHaveBeenCalledWith('external_link', expect.objectContaining({ destination: 'linkedin' }));
54+
});
55+
56+
it('calls onTrackEvent when clicking mcp link', async () => {
57+
const onTrackEvent = vi.fn();
58+
const user = userEvent.setup();
59+
60+
render(<Footer onTrackEvent={onTrackEvent} />);
61+
62+
await user.click(screen.getByText('mcp'));
63+
expect(onTrackEvent).toHaveBeenCalledWith('internal_link', expect.objectContaining({ destination: 'mcp' }));
64+
});
65+
66+
it('calls onTrackEvent when clicking legal link', async () => {
67+
const onTrackEvent = vi.fn();
68+
const user = userEvent.setup();
69+
70+
render(<Footer onTrackEvent={onTrackEvent} />);
71+
72+
await user.click(screen.getByText('legal'));
73+
expect(onTrackEvent).toHaveBeenCalledWith('internal_link', expect.objectContaining({ destination: 'legal' }));
74+
});
75+
76+
it('passes selectedSpec and selectedLibrary to tracking', async () => {
77+
const onTrackEvent = vi.fn();
78+
const user = userEvent.setup();
79+
80+
render(<Footer onTrackEvent={onTrackEvent} selectedSpec="scatter-basic" selectedLibrary="matplotlib" />);
81+
82+
await user.click(screen.getByText('github'));
83+
expect(onTrackEvent).toHaveBeenCalledWith('external_link', {
84+
destination: 'github',
85+
spec: 'scatter-basic',
86+
library: 'matplotlib',
87+
});
88+
});
89+
90+
it('renders github link with correct href', () => {
91+
render(<Footer />);
92+
93+
const githubLink = screen.getByText('github').closest('a');
94+
expect(githubLink).toHaveAttribute('href', 'https://github.com/MarkusNeusinger/pyplots');
95+
expect(githubLink).toHaveAttribute('target', '_blank');
96+
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
97+
});
98+
99+
it('renders mcp as internal router link to /mcp', () => {
100+
render(<Footer />);
101+
102+
const mcpLink = screen.getByText('mcp').closest('a');
103+
expect(mcpLink).toHaveAttribute('href', '/mcp');
104+
});
105+
106+
it('renders legal as internal router link to /legal', () => {
107+
render(<Footer />);
108+
109+
const legalLink = screen.getByText('legal').closest('a');
110+
expect(legalLink).toHaveAttribute('href', '/legal');
111+
});
45112
});

app/src/components/Header.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { fireEvent } from '@testing-library/react';
3+
import { render, screen, userEvent } from '../test-utils';
4+
import { Header } from './Header';
5+
6+
// Mock useNavigate
7+
const mockNavigate = vi.fn();
8+
vi.mock('react-router-dom', async () => {
9+
const actual = await vi.importActual('react-router-dom');
10+
return { ...actual, useNavigate: () => mockNavigate };
11+
});
12+
13+
describe('Header', () => {
14+
beforeEach(() => {
15+
mockNavigate.mockClear();
16+
});
17+
18+
it('renders the pyplots.ai logo text', () => {
19+
render(<Header />);
20+
21+
expect(screen.getByText('py')).toBeInTheDocument();
22+
expect(screen.getByText('plots')).toBeInTheDocument();
23+
expect(screen.getByText('.ai')).toBeInTheDocument();
24+
});
25+
26+
it('renders tagline text', () => {
27+
render(<Header />);
28+
29+
expect(screen.getByText(/ai-powered python/i)).toBeInTheDocument();
30+
});
31+
32+
it('renders "get inspired" call to action', () => {
33+
render(<Header />);
34+
35+
expect(screen.getByText(/get inspired/)).toBeInTheDocument();
36+
});
37+
38+
it('renders stats tooltip content when stats provided', () => {
39+
render(<Header stats={{ specs: 254, plots: 1800, libraries: 9 }} />);
40+
41+
expect(screen.getByText('✦')).toBeInTheDocument();
42+
});
43+
44+
it('renders shuffle icon when onRandom is provided', () => {
45+
render(<Header onRandom={vi.fn()} />);
46+
47+
expect(screen.getByRole('button', { name: /random filter/i })).toBeInTheDocument();
48+
});
49+
50+
it('does not render shuffle icon when onRandom is not provided', () => {
51+
render(<Header />);
52+
53+
expect(screen.queryByRole('button', { name: /random filter/i })).toBeNull();
54+
});
55+
56+
it('calls onRandom with "click" when shuffle icon is clicked', async () => {
57+
const onRandom = vi.fn();
58+
const user = userEvent.setup();
59+
60+
render(<Header onRandom={onRandom} />);
61+
62+
await user.click(screen.getByRole('button', { name: /random filter/i }));
63+
expect(onRandom).toHaveBeenCalledWith('click');
64+
});
65+
66+
it('navigates to "/" on single click of logo', () => {
67+
vi.useFakeTimers();
68+
render(<Header />);
69+
70+
const logo = screen.getByRole('link');
71+
fireEvent.click(logo);
72+
73+
// Wait for the 400ms debounce
74+
vi.advanceTimersByTime(400);
75+
76+
expect(mockNavigate).toHaveBeenCalledWith('/');
77+
vi.useRealTimers();
78+
});
79+
80+
it('navigates to "/debug" on triple click of logo', () => {
81+
vi.useFakeTimers();
82+
render(<Header />);
83+
84+
const logo = screen.getByRole('link');
85+
fireEvent.click(logo);
86+
fireEvent.click(logo);
87+
fireEvent.click(logo);
88+
89+
expect(mockNavigate).toHaveBeenCalledWith('/debug');
90+
vi.useRealTimers();
91+
});
92+
93+
it('has a header element', () => {
94+
render(<Header />);
95+
96+
expect(screen.getByRole('banner')).toBeInTheDocument();
97+
});
98+
99+
it('renders heading as h1', () => {
100+
render(<Header />);
101+
102+
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
103+
});
104+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, userEvent } from '../test-utils';
3+
import { CatalogLink, GridSizeToggle, ToolbarActions } from './ToolbarActions';
4+
5+
describe('CatalogLink', () => {
6+
it('renders a link to /catalog', () => {
7+
render(<CatalogLink />);
8+
9+
const link = screen.getByRole('link');
10+
expect(link).toHaveAttribute('href', '/catalog');
11+
});
12+
13+
it('has a "catalog" tooltip', async () => {
14+
const user = userEvent.setup();
15+
render(<CatalogLink />);
16+
17+
const link = screen.getByRole('link');
18+
await user.hover(link);
19+
20+
expect(await screen.findByText('catalog')).toBeInTheDocument();
21+
});
22+
});
23+
24+
describe('GridSizeToggle', () => {
25+
const defaultProps = {
26+
imageSize: 'normal' as const,
27+
onImageSizeChange: vi.fn(),
28+
onTrackEvent: vi.fn(),
29+
};
30+
31+
it('renders with aria-label for current state', () => {
32+
render(<GridSizeToggle {...defaultProps} />);
33+
34+
expect(screen.getByRole('button')).toHaveAttribute(
35+
'aria-label',
36+
'Switch to compact view'
37+
);
38+
});
39+
40+
it('shows "Switch to normal view" when in compact mode', () => {
41+
render(<GridSizeToggle {...defaultProps} imageSize="compact" />);
42+
43+
expect(screen.getByRole('button')).toHaveAttribute(
44+
'aria-label',
45+
'Switch to normal view'
46+
);
47+
});
48+
49+
it('toggles to compact on click and fires tracking event', async () => {
50+
const onImageSizeChange = vi.fn();
51+
const onTrackEvent = vi.fn();
52+
const user = userEvent.setup();
53+
54+
render(
55+
<GridSizeToggle
56+
imageSize="normal"
57+
onImageSizeChange={onImageSizeChange}
58+
onTrackEvent={onTrackEvent}
59+
/>
60+
);
61+
62+
await user.click(screen.getByRole('button'));
63+
64+
expect(onImageSizeChange).toHaveBeenCalledWith('compact');
65+
expect(onTrackEvent).toHaveBeenCalledWith('grid_resize', { size: 'compact' });
66+
});
67+
68+
it('toggles to normal when already compact', async () => {
69+
const onImageSizeChange = vi.fn();
70+
const user = userEvent.setup();
71+
72+
render(
73+
<GridSizeToggle
74+
{...defaultProps}
75+
imageSize="compact"
76+
onImageSizeChange={onImageSizeChange}
77+
/>
78+
);
79+
80+
await user.click(screen.getByRole('button'));
81+
expect(onImageSizeChange).toHaveBeenCalledWith('normal');
82+
});
83+
84+
it('responds to Enter key', async () => {
85+
const onImageSizeChange = vi.fn();
86+
const user = userEvent.setup();
87+
88+
render(<GridSizeToggle {...defaultProps} onImageSizeChange={onImageSizeChange} />);
89+
90+
screen.getByRole('button').focus();
91+
await user.keyboard('{Enter}');
92+
93+
expect(onImageSizeChange).toHaveBeenCalledWith('compact');
94+
});
95+
96+
it('responds to Space key', async () => {
97+
const onImageSizeChange = vi.fn();
98+
const user = userEvent.setup();
99+
100+
render(<GridSizeToggle {...defaultProps} onImageSizeChange={onImageSizeChange} />);
101+
102+
screen.getByRole('button').focus();
103+
await user.keyboard(' ');
104+
105+
expect(onImageSizeChange).toHaveBeenCalledWith('compact');
106+
});
107+
});
108+
109+
describe('ToolbarActions', () => {
110+
it('renders both CatalogLink and GridSizeToggle', () => {
111+
render(
112+
<ToolbarActions
113+
imageSize="normal"
114+
onImageSizeChange={vi.fn()}
115+
onTrackEvent={vi.fn()}
116+
/>
117+
);
118+
119+
expect(screen.getByRole('link')).toHaveAttribute('href', '/catalog');
120+
expect(screen.getByRole('button')).toBeInTheDocument();
121+
});
122+
});

0 commit comments

Comments
 (0)