Skip to content

Commit 3b18c56

Browse files
Add comprehensive test suite for frontend and backend (#5277)
## Summary This PR adds extensive test coverage across the frontend (React components and utilities) and backend (API routes, database models, and helper functions). The test suite includes unit tests for core functionality, integration tests for API endpoints, and component tests for UI elements. ## Key Changes ### Frontend Tests (React/TypeScript) - **Component Tests**: Added comprehensive tests for `SpecTabs`, `SpecOverview`, `SpecDetailView`, `CodeHighlighter`, `FilterBar`, and `Layout` components - **Page Tests**: Added tests for `HomePage`, `CatalogPage`, `SpecPage`, `StatsPage`, `InteractivePage`, and `DebugPage` - **Hook Tests**: Added extended tests for `useFilterState` hook - **Utility Tests**: Added tests for filter utilities (`filters-extended.test.ts`) ### Backend Tests (Python) - **API Tests**: - `test_plots_helpers.py`: Tests for plot filtering helper functions (`_get_category_values`, `_category_matches_filter`, `_image_matches_groups`, etc.) - `test_insights_helpers.py`: Tests for insights helper functions (`_score_bucket`, `_flatten_tags`, `_collect_impl_tags`, `_parse_iso`) - `test_seo_helpers.py`: Tests for SEO helper functions (`_lastmod`, `_build_sitemap_xml`) - `test_schemas.py`: Tests for Pydantic API schemas (`ImplementationResponse`, `SpecDetailResponse`, `FilterCountsResponse`, etc.) - `test_analytics_extended.py`: Extended tests for analytics platform detection - **Database Tests**: - `test_models.py`: Tests for ORM models (`Spec`, `Library`, `Impl`) including model constants and persistence - `test_repositories.py`: Tests for repository classes (`SpecRepository`, `LibraryRepository`, `ImplRepository`) with in-memory SQLite - **Automation Tests**: - `test_sync_helpers.py`: Tests for sync helper functions (`_validate_quality_score`, `_parse_markdown_section`, `_validate_spec_id`) - **Config Tests**: - `test_config_resolve.py`: Tests for `Settings.resolve_model` method for different CLI model tier resolution ### Bug Fixes - Fixed Python 3.10+ syntax error in `agentic/workflows/modules/orchestrator.py`: Changed `except json.JSONDecodeError, ValueError:` to `except (json.JSONDecodeError, ValueError):` - Fixed similar syntax error in `agentic/workflows/modules/state.py` - Fixed syntax error in `agentic/workflows/modules/agent.py` ## Notable Implementation Details - Frontend tests use Vitest with React Testing Library and custom test utilities - Backend tests use pytest with async support for database operations - Tests include mocking of external dependencies (fetch, localStorage, routing) - Database tests use in-memory SQLite for isolation - Comprehensive coverage of edge cases, error conditions, and boundary values - Tests validate both happy paths and error scenarios https://claude.ai/code/session_01KhAhJKpEoqCzmWzcALSfW6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e743d59 commit 3b18c56

26 files changed

+3840
-5
lines changed

agentic/workflows/modules/agent.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def parse_json(output: str, target_type: Type[T] = None) -> Any:
144144
return [target_type.model_validate(item) for item in parsed]
145145
return target_type.model_validate(parsed)
146146
return parsed
147-
except json.JSONDecodeError, ValueError:
147+
except (json.JSONDecodeError, ValueError): # fmt: skip
148148
pass
149149

150150
# Strategy 2: Strip markdown code fences
@@ -162,7 +162,7 @@ def parse_json(output: str, target_type: Type[T] = None) -> Any:
162162
return [target_type.model_validate(item) for item in parsed]
163163
return target_type.model_validate(parsed)
164164
return parsed
165-
except json.JSONDecodeError, ValueError:
165+
except (json.JSONDecodeError, ValueError): # fmt: skip
166166
pass
167167

168168
# Strategy 3: Find first JSON array or object in output
@@ -182,7 +182,7 @@ def parse_json(output: str, target_type: Type[T] = None) -> Any:
182182
return [target_type.model_validate(item) for item in parsed]
183183
return target_type.model_validate(parsed)
184184
return parsed
185-
except json.JSONDecodeError, ValueError:
185+
except (json.JSONDecodeError, ValueError): # fmt: skip
186186
continue
187187

188188
raise json.JSONDecodeError("No valid JSON found in output", output, 0)

agentic/workflows/modules/orchestrator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ def extract_run_id(stdout: str) -> str | None:
4545
try:
4646
data = json.loads(stdout.strip())
4747
return data.get("run_id")
48-
except json.JSONDecodeError, ValueError:
48+
except (json.JSONDecodeError, ValueError): # fmt: skip
4949
return None

agentic/workflows/modules/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def from_stdin(cls) -> Optional["WorkflowState"]:
172172
state = cls(run_id=run_id, prompt=data.get("prompt", ""))
173173
state.data = data
174174
return state
175-
except json.JSONDecodeError, EOFError:
175+
except (json.JSONDecodeError, EOFError): # fmt: skip
176176
return None
177177

178178
def to_stdout(self) -> None:
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)