Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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):
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):
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):
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):
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):
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