Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions agentic/workflows/modules/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def parse_json(output: str, target_type: Type[T] = None) -> Any:
return [target_type.model_validate(item) for item in parsed]
return target_type.model_validate(parsed)
return parsed
except json.JSONDecodeError, ValueError:
except (json.JSONDecodeError, ValueError): # fmt: skip
pass

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

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

raise json.JSONDecodeError("No valid JSON found in output", output, 0)
Expand Down
2 changes: 1 addition & 1 deletion agentic/workflows/modules/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ def extract_run_id(stdout: str) -> str | None:
try:
data = json.loads(stdout.strip())
return data.get("run_id")
except json.JSONDecodeError, ValueError:
except (json.JSONDecodeError, ValueError): # fmt: skip
return None
2 changes: 1 addition & 1 deletion agentic/workflows/modules/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def from_stdin(cls) -> Optional["WorkflowState"]:
state = cls(run_id=run_id, prompt=data.get("prompt", ""))
state.data = data
return state
except json.JSONDecodeError, EOFError:
except (json.JSONDecodeError, EOFError): # fmt: skip
return None

def to_stdout(self) -> None:
Expand Down
54 changes: 54 additions & 0 deletions app/src/components/CodeHighlighter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../test-utils';

vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => {
const MockHighlighter = ({
children,
language,
...props
}: {
children: string;
language: string;
style?: object;
customStyle?: object;
}) => (
<pre data-testid="syntax-highlighter" data-language={language} {...props}>
{children}
</pre>
);
MockHighlighter.registerLanguage = vi.fn();
return { default: MockHighlighter };
});

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
oneLight: {},
}));

vi.mock('react-syntax-highlighter/dist/esm/languages/prism/python', () => ({
default: {},
}));

import CodeHighlighter from './CodeHighlighter';

describe('CodeHighlighter', () => {
it('renders without crashing', () => {
render(<CodeHighlighter code="x = 1" />);
expect(screen.getByTestId('syntax-highlighter')).toBeInTheDocument();
});

it('renders the provided code text', () => {
const code = 'import matplotlib.pyplot as plt\nplt.show()';
render(<CodeHighlighter code={code} />);
const highlighter = screen.getByTestId('syntax-highlighter');
expect(highlighter).toHaveTextContent('import matplotlib.pyplot as plt');
expect(highlighter).toHaveTextContent('plt.show()');
});

it('sets language to python', () => {
render(<CodeHighlighter code="print('hello')" />);
expect(screen.getByTestId('syntax-highlighter')).toHaveAttribute(
'data-language',
'python'
);
});
});
84 changes: 84 additions & 0 deletions app/src/components/FilterBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '../test-utils';

// Mock the utils module
vi.mock('../utils', () => ({
getAvailableValues: vi.fn(() => [['scatter', 10], ['bar', 5]]),
getAvailableValuesForGroup: vi.fn(() => [['scatter', 15]]),
getSearchResults: vi.fn(() => []),
}));

import { FilterBar } from './FilterBar';

// ResizeObserver polyfill
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}

const defaultProps = {
activeFilters: [] as { category: 'lib'; values: string[] }[],
filterCounts: {
lib: { matplotlib: 100, seaborn: 80 },
spec: {}, plot: {}, data: {}, dom: {}, feat: {},
dep: {}, tech: {}, pat: {}, prep: {}, style: {},
},
orCounts: [] as Record<string, number>[],
specTitles: {},
currentTotal: 100,
displayedCount: 20,
randomAnimation: null,
imageSize: 'normal' as const,
onImageSizeChange: vi.fn(),
onAddFilter: vi.fn(),
onAddValueToGroup: vi.fn(),
onRemoveFilter: vi.fn(),
onRemoveGroup: vi.fn(),
onTrackEvent: vi.fn(),
};

describe('FilterBar', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('ResizeObserver', MockResizeObserver);
});

it('renders without crashing', () => {
render(<FilterBar {...defaultProps} />);
// Component should mount and have an input
expect(document.querySelector('input')).toBeTruthy();
});

it('renders active filter chip with category:value format', () => {
const filters = [
{ category: 'lib' as const, values: ['matplotlib'] },
];
render(<FilterBar {...defaultProps} activeFilters={filters} />);
// Chip label is "category:value" format
expect(screen.getByText('lib:matplotlib')).toBeInTheDocument();
});

it('shows counter text with total', () => {
render(<FilterBar {...defaultProps} currentTotal={42} displayedCount={20} />);
expect(screen.getByText(/42/)).toBeInTheDocument();
});

it('renders chip for each filter group', () => {
const filters = [
{ category: 'lib' as const, values: ['matplotlib'] },
{ category: 'plot' as const, values: ['scatter'] },
];
render(<FilterBar {...defaultProps} activeFilters={filters} />);
const chips = document.querySelectorAll('.MuiChip-root');
expect(chips.length).toBeGreaterThanOrEqual(2);
});

it('renders comma-separated values in chip', () => {
const filters = [
{ category: 'lib' as const, values: ['matplotlib', 'seaborn'] },
];
render(<FilterBar {...defaultProps} activeFilters={filters} />);
expect(screen.getByText('lib:matplotlib,seaborn')).toBeInTheDocument();
});
});
130 changes: 130 additions & 0 deletions app/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { AppDataProvider, Layout } from './Layout';
import { AppDataContext } from '../hooks/useLayoutContext';
import { useContext } from 'react';

vi.mock('react-helmet-async', () => ({
Helmet: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

// jsdom does not have requestIdleCallback / cancelIdleCallback
vi.stubGlobal(
'requestIdleCallback',
vi.fn((cb: IdleRequestCallback) => {
const id = setTimeout(() => cb({} as IdleDeadline), 0);
return id as unknown as number;
}),
);
vi.stubGlobal('cancelIdleCallback', vi.fn((id: number) => clearTimeout(id)));

const theme = createTheme();

function wrap(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider theme={theme}>
<MemoryRouter>{children}</MemoryRouter>
</ThemeProvider>
),
});
}

describe('Layout', () => {
it('renders children via Outlet', () => {
// Layout uses <Outlet />, which renders nothing without route context,
// but the wrapper itself renders without errors.
wrap(<Layout />);

// The main Box should be present
const main = document.querySelector('main');
expect(main).toBeInTheDocument();
});
});

describe('AppDataProvider', () => {
beforeEach(() => {
vi.restoreAllMocks();
// Re-stub after restoreAllMocks clears them
vi.stubGlobal(
'requestIdleCallback',
vi.fn((cb: IdleRequestCallback) => {
const id = setTimeout(() => cb({} as IdleDeadline), 0);
return id as unknown as number;
}),
);
vi.stubGlobal('cancelIdleCallback', vi.fn((id: number) => clearTimeout(id)));
});

it('provides context to children', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ specs: [], libraries: [], specs_count: 0, plots_count: 0, libraries_count: 0 }),
}),
);

function Consumer() {
const ctx = useContext(AppDataContext);
return <div data-testid="ctx">{ctx ? 'has-context' : 'no-context'}</div>;
}

wrap(
<AppDataProvider>
<Consumer />
</AppDataProvider>,
);

expect(screen.getByTestId('ctx')).toHaveTextContent('has-context');
});

it('calls fetch for /specs, /libraries, and /stats', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
vi.stubGlobal('fetch', fetchMock);

wrap(
<AppDataProvider>
<div>child</div>
</AppDataProvider>,
);

await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});

const urls = fetchMock.mock.calls.map((c: unknown[]) => c[0] as string);
expect(urls.some((u: string) => u.includes('/specs'))).toBe(true);
expect(urls.some((u: string) => u.includes('/libraries'))).toBe(true);
expect(urls.some((u: string) => u.includes('/stats'))).toBe(true);
});

it('handles fetch failure gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new Error('Network error')),
);

wrap(
<AppDataProvider>
<div data-testid="child">still renders</div>
</AppDataProvider>,
);

await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Initial data load incomplete:',
'Network error',
);
});

expect(screen.getByTestId('child')).toHaveTextContent('still renders');
consoleSpy.mockRestore();
});
});
Loading
Loading