Skip to content

Commit b1f5ffb

Browse files
committed
improve frontend test coverage from 33% to 71%
Add comprehensive tests for all previously untested components and pages: Components: - SpecTabs.test.tsx: 22 tests (tabs, toggle, copy, quality, tags) - SpecDetailView.test.tsx: 13 tests (zoom, actions, overlay, counter) - SpecOverview.test.tsx: 13 tests (grid, sorting, cards, tooltips) - FilterBar.test.tsx: 5 tests (chips, counter, filter groups) - Layout.test.tsx: 4 tests (provider, context, fetch) - CodeHighlighter.test.tsx: 3 tests (render, language) Pages: - StatsPage.test.tsx: 9 tests (dashboard, stats, loading, error) - CatalogPage.test.tsx: 8 tests (specs, grouping, loading) - SpecPage.test.tsx: 10 tests (overview/detail modes, 404, fetch) - DebugPage.test.tsx: 4 tests (debug data, loading, error) - HomePage.test.tsx: 4 tests (components, POTD, grid) - InteractivePage.test.tsx: 4 tests (iframe, loading, error) Utils/Hooks: - filters-extended.test.ts: 17 tests (getAvailableValues, search) - useFilterState-extended.test.ts: 7 tests (isFiltersEmpty) https://claude.ai/code/session_01KhAhJKpEoqCzmWzcALSfW6
1 parent 905d6be commit b1f5ffb

14 files changed

Lines changed: 2167 additions & 0 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '../test-utils';
3+
4+
vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => {
5+
const MockHighlighter = ({
6+
children,
7+
language,
8+
...props
9+
}: {
10+
children: string;
11+
language: string;
12+
style?: object;
13+
customStyle?: object;
14+
}) => (
15+
<pre data-testid="syntax-highlighter" data-language={language} {...props}>
16+
{children}
17+
</pre>
18+
);
19+
MockHighlighter.registerLanguage = vi.fn();
20+
return { default: MockHighlighter };
21+
});
22+
23+
vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
24+
oneLight: {},
25+
}));
26+
27+
vi.mock('react-syntax-highlighter/dist/esm/languages/prism/python', () => ({
28+
default: {},
29+
}));
30+
31+
import CodeHighlighter from './CodeHighlighter';
32+
33+
describe('CodeHighlighter', () => {
34+
it('renders without crashing', () => {
35+
render(<CodeHighlighter code="x = 1" />);
36+
expect(screen.getByTestId('syntax-highlighter')).toBeInTheDocument();
37+
});
38+
39+
it('renders the provided code text', () => {
40+
const code = 'import matplotlib.pyplot as plt\nplt.show()';
41+
render(<CodeHighlighter code={code} />);
42+
const highlighter = screen.getByTestId('syntax-highlighter');
43+
expect(highlighter).toHaveTextContent('import matplotlib.pyplot as plt');
44+
expect(highlighter).toHaveTextContent('plt.show()');
45+
});
46+
47+
it('sets language to python', () => {
48+
render(<CodeHighlighter code="print('hello')" />);
49+
expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute(
50+
'data-language',
51+
'python'
52+
);
53+
});
54+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen } from '../test-utils';
3+
4+
// Mock the utils module
5+
vi.mock('../utils', () => ({
6+
getAvailableValues: vi.fn(() => [['scatter', 10], ['bar', 5]]),
7+
getAvailableValuesForGroup: vi.fn(() => [['scatter', 15]]),
8+
getSearchResults: vi.fn(() => []),
9+
}));
10+
11+
import { FilterBar } from './FilterBar';
12+
13+
// ResizeObserver polyfill
14+
class MockResizeObserver {
15+
observe = vi.fn();
16+
unobserve = vi.fn();
17+
disconnect = vi.fn();
18+
}
19+
20+
const defaultProps = {
21+
activeFilters: [] as { category: 'lib'; values: string[] }[],
22+
filterCounts: {
23+
lib: { matplotlib: 100, seaborn: 80 },
24+
spec: {}, plot: {}, data: {}, dom: {}, feat: {},
25+
dep: {}, tech: {}, pat: {}, prep: {}, style: {},
26+
},
27+
orCounts: [] as Record<string, number>[],
28+
specTitles: {},
29+
currentTotal: 100,
30+
displayedCount: 20,
31+
randomAnimation: null,
32+
imageSize: 'normal' as const,
33+
onImageSizeChange: vi.fn(),
34+
onAddFilter: vi.fn(),
35+
onAddValueToGroup: vi.fn(),
36+
onRemoveFilter: vi.fn(),
37+
onRemoveGroup: vi.fn(),
38+
onTrackEvent: vi.fn(),
39+
};
40+
41+
describe('FilterBar', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
45+
});
46+
47+
it('renders without crashing', () => {
48+
render(<FilterBar {...defaultProps} />);
49+
// Component should mount and have an input
50+
expect(document.querySelector('input')).toBeTruthy();
51+
});
52+
53+
it('renders active filter chip with category:value format', () => {
54+
const filters = [
55+
{ category: 'lib' as const, values: ['matplotlib'] },
56+
];
57+
render(<FilterBar {...defaultProps} activeFilters={filters} />);
58+
// Chip label is "category:value" format
59+
expect(screen.getByText('lib:matplotlib')).toBeInTheDocument();
60+
});
61+
62+
it('shows counter text with total', () => {
63+
render(<FilterBar {...defaultProps} currentTotal={42} displayedCount={20} />);
64+
expect(screen.getByText(/42/)).toBeInTheDocument();
65+
});
66+
67+
it('renders chip for each filter group', () => {
68+
const filters = [
69+
{ category: 'lib' as const, values: ['matplotlib'] },
70+
{ category: 'plot' as const, values: ['scatter'] },
71+
];
72+
render(<FilterBar {...defaultProps} activeFilters={filters} />);
73+
const chips = document.querySelectorAll('.MuiChip-root');
74+
expect(chips.length).toBeGreaterThanOrEqual(2);
75+
});
76+
77+
it('renders comma-separated values in chip', () => {
78+
const filters = [
79+
{ category: 'lib' as const, values: ['matplotlib', 'seaborn'] },
80+
];
81+
render(<FilterBar {...defaultProps} activeFilters={filters} />);
82+
expect(screen.getByText('lib:matplotlib,seaborn')).toBeInTheDocument();
83+
});
84+
});

app/src/components/Layout.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import { MemoryRouter } from 'react-router-dom';
4+
import { ThemeProvider, createTheme } from '@mui/material/styles';
5+
import { AppDataProvider, Layout } from './Layout';
6+
import { AppDataContext } from '../hooks/useLayoutContext';
7+
import { useContext } from 'react';
8+
9+
vi.mock('react-helmet-async', () => ({
10+
Helmet: ({ children }: { children: React.ReactNode }) => <>{children}</>,
11+
}));
12+
13+
// jsdom does not have requestIdleCallback / cancelIdleCallback
14+
vi.stubGlobal(
15+
'requestIdleCallback',
16+
vi.fn((cb: IdleRequestCallback) => {
17+
const id = setTimeout(() => cb({} as IdleDeadline), 0);
18+
return id as unknown as number;
19+
}),
20+
);
21+
vi.stubGlobal('cancelIdleCallback', vi.fn((id: number) => clearTimeout(id)));
22+
23+
const theme = createTheme();
24+
25+
function wrap(ui: React.ReactElement) {
26+
return render(ui, {
27+
wrapper: ({ children }) => (
28+
<ThemeProvider theme={theme}>
29+
<MemoryRouter>{children}</MemoryRouter>
30+
</ThemeProvider>
31+
),
32+
});
33+
}
34+
35+
describe('Layout', () => {
36+
it('renders children via Outlet', () => {
37+
// Layout uses <Outlet />, which renders nothing without route context,
38+
// but the wrapper itself renders without errors.
39+
wrap(<Layout />);
40+
41+
// The main Box should be present
42+
const main = document.querySelector('main');
43+
expect(main).toBeInTheDocument();
44+
});
45+
});
46+
47+
describe('AppDataProvider', () => {
48+
beforeEach(() => {
49+
vi.restoreAllMocks();
50+
// Re-stub after restoreAllMocks clears them
51+
vi.stubGlobal(
52+
'requestIdleCallback',
53+
vi.fn((cb: IdleRequestCallback) => {
54+
const id = setTimeout(() => cb({} as IdleDeadline), 0);
55+
return id as unknown as number;
56+
}),
57+
);
58+
vi.stubGlobal('cancelIdleCallback', vi.fn((id: number) => clearTimeout(id)));
59+
});
60+
61+
it('provides context to children', async () => {
62+
vi.stubGlobal(
63+
'fetch',
64+
vi.fn().mockResolvedValue({
65+
ok: true,
66+
json: () => Promise.resolve({ specs: [], libraries: [], specs_count: 0, plots_count: 0, libraries_count: 0 }),
67+
}),
68+
);
69+
70+
function Consumer() {
71+
const ctx = useContext(AppDataContext);
72+
return <div data-testid="ctx">{ctx ? 'has-context' : 'no-context'}</div>;
73+
}
74+
75+
wrap(
76+
<AppDataProvider>
77+
<Consumer />
78+
</AppDataProvider>,
79+
);
80+
81+
expect(screen.getByTestId('ctx')).toHaveTextContent('has-context');
82+
});
83+
84+
it('calls fetch for /specs, /libraries, and /stats', async () => {
85+
const fetchMock = vi.fn().mockResolvedValue({
86+
ok: true,
87+
json: () => Promise.resolve({}),
88+
});
89+
vi.stubGlobal('fetch', fetchMock);
90+
91+
wrap(
92+
<AppDataProvider>
93+
<div>child</div>
94+
</AppDataProvider>,
95+
);
96+
97+
await waitFor(() => {
98+
expect(fetchMock).toHaveBeenCalledTimes(3);
99+
});
100+
101+
const urls = fetchMock.mock.calls.map((c: unknown[]) => c[0] as string);
102+
expect(urls.some((u: string) => u.includes('/specs'))).toBe(true);
103+
expect(urls.some((u: string) => u.includes('/libraries'))).toBe(true);
104+
expect(urls.some((u: string) => u.includes('/stats'))).toBe(true);
105+
});
106+
107+
it('handles fetch failure gracefully', async () => {
108+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
109+
vi.stubGlobal(
110+
'fetch',
111+
vi.fn().mockRejectedValue(new Error('Network error')),
112+
);
113+
114+
wrap(
115+
<AppDataProvider>
116+
<div data-testid="child">still renders</div>
117+
</AppDataProvider>,
118+
);
119+
120+
await waitFor(() => {
121+
expect(consoleSpy).toHaveBeenCalledWith(
122+
'Initial data load incomplete:',
123+
'Network error',
124+
);
125+
});
126+
127+
expect(screen.getByTestId('child')).toHaveTextContent('still renders');
128+
consoleSpy.mockRestore();
129+
});
130+
});

0 commit comments

Comments
 (0)