Skip to content

Commit ae620eb

Browse files
authored
test: extra frontend/unit tests (#239)
* test: extra frontend/unit tests * fix: quicker frontend/unit + consistency in imports (all use aliases instead of relative paths) * fix: extracted common objs to test utils * fix: extra unit tests * fix: detected issues
1 parent a9d521c commit ae620eb

46 files changed

Lines changed: 2266 additions & 292 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/src/__tests__/test-utils.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@ import type {
1818
NotificationResponse,
1919
EventMetadata,
2020
EventType,
21+
AdminExecutionResponse,
22+
QueueStatusResponse,
23+
SagaStatusResponse,
24+
UserResponse,
2125
} from '$lib/api';
2226

2327
export type UserEventInstance = ReturnType<typeof userEvent.setup>;
2428

25-
export const user: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
29+
export const user: UserEventInstance = userEvent.setup({
30+
delay: null,
31+
pointerEventsCheck: 0,
32+
});
2633

2734
// ============================================================================
2835
// Mock Store Type (for use with vi.hoisted)
@@ -236,3 +243,121 @@ export function createMockUserOverview(): AdminUserOverview {
236243
recent_events: [createMockEvent() as AdminUserOverview['recent_events'][number]],
237244
};
238245
}
246+
247+
// ============================================================================
248+
// Admin Execution Mock Helpers
249+
// ============================================================================
250+
251+
export const DEFAULT_EXECUTION: AdminExecutionResponse = {
252+
execution_id: 'exec-1',
253+
script: 'print("hi")',
254+
status: 'queued',
255+
lang: 'python',
256+
lang_version: '3.11',
257+
priority: 'normal',
258+
user_id: 'user-1',
259+
stdout: null,
260+
stderr: null,
261+
exit_code: null,
262+
error_type: null,
263+
created_at: '2024-01-15T10:30:00Z',
264+
updated_at: '2024-01-15T10:30:00Z',
265+
};
266+
267+
export const createMockExecution = (overrides: Partial<AdminExecutionResponse> = {}): AdminExecutionResponse => ({
268+
...DEFAULT_EXECUTION,
269+
...overrides,
270+
});
271+
272+
const EXECUTION_STATUSES: AdminExecutionResponse['status'][] = [
273+
'queued', 'scheduled', 'running', 'completed', 'failed', 'timeout', 'cancelled', 'error',
274+
];
275+
const EXECUTION_PRIORITIES: AdminExecutionResponse['priority'][] = [
276+
'critical', 'high', 'normal', 'low', 'background',
277+
];
278+
279+
export const createMockExecutions = (count: number): AdminExecutionResponse[] =>
280+
Array.from({ length: count }, (_, i) => createMockExecution({
281+
execution_id: `exec-${i + 1}`,
282+
status: EXECUTION_STATUSES[i % EXECUTION_STATUSES.length],
283+
priority: EXECUTION_PRIORITIES[i % EXECUTION_PRIORITIES.length],
284+
user_id: `user-${(i % 3) + 1}`,
285+
created_at: new Date(Date.now() - i * 60000).toISOString(),
286+
}));
287+
288+
export const createMockQueueStatus = (overrides: Partial<QueueStatusResponse> = {}): QueueStatusResponse => ({
289+
queue_depth: 5,
290+
active_count: 2,
291+
max_concurrent: 10,
292+
by_priority: { normal: 3, high: 2 },
293+
...overrides,
294+
});
295+
296+
// ============================================================================
297+
// Admin Saga Mock Helpers
298+
// ============================================================================
299+
300+
export const DEFAULT_SAGA: SagaStatusResponse = {
301+
saga_id: 'saga-1',
302+
saga_name: 'execution_saga',
303+
execution_id: 'exec-123',
304+
state: 'running',
305+
current_step: 'create_pod',
306+
completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'],
307+
compensated_steps: [],
308+
retry_count: 0,
309+
error_message: null,
310+
created_at: '2024-01-15T10:30:00Z',
311+
updated_at: '2024-01-15T10:31:00Z',
312+
completed_at: null,
313+
};
314+
315+
export const createMockSaga = (overrides: Partial<SagaStatusResponse> = {}): SagaStatusResponse => ({
316+
...DEFAULT_SAGA,
317+
...overrides,
318+
});
319+
320+
const SAGA_STATES: SagaStatusResponse['state'][] = [
321+
'created', 'running', 'completed', 'failed', 'compensating', 'timeout',
322+
];
323+
324+
export const createMockSagas = (count: number): SagaStatusResponse[] =>
325+
Array.from({ length: count }, (_, i) => createMockSaga({
326+
saga_id: `saga-${i + 1}`,
327+
execution_id: `exec-${i + 1}`,
328+
state: SAGA_STATES[i % SAGA_STATES.length],
329+
created_at: new Date(Date.now() - i * 60000).toISOString(),
330+
updated_at: new Date(Date.now() - i * 30000).toISOString(),
331+
}));
332+
333+
// ============================================================================
334+
// Admin User Mock Helpers
335+
// ============================================================================
336+
337+
export const DEFAULT_USER: UserResponse = {
338+
user_id: 'user-1',
339+
username: 'testuser',
340+
email: 'test@example.com',
341+
role: 'user',
342+
is_active: true,
343+
is_superuser: false,
344+
created_at: '2024-01-15T10:30:00Z',
345+
updated_at: '2024-01-15T10:30:00Z',
346+
bypass_rate_limit: false,
347+
global_multiplier: 1.0,
348+
has_custom_limits: false,
349+
};
350+
351+
export const createMockUser = (overrides: Partial<UserResponse> = {}): UserResponse => ({
352+
...DEFAULT_USER,
353+
...overrides,
354+
});
355+
356+
export const createMockUsers = (count: number): UserResponse[] =>
357+
Array.from({ length: count }, (_, i) => createMockUser({
358+
user_id: `user-${i + 1}`,
359+
username: `user${i + 1}`,
360+
email: `user${i + 1}@example.com`,
361+
role: i === 0 ? 'admin' : 'user',
362+
is_active: i % 3 !== 0,
363+
}));
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render } from '@testing-library/svelte';
3+
import EventTypeIcon from '$components/EventTypeIcon.svelte';
4+
5+
describe('EventTypeIcon', () => {
6+
beforeEach(() => {
7+
vi.clearAllMocks();
8+
});
9+
10+
describe('known event types', () => {
11+
it.each([
12+
'execution.requested',
13+
'execution_requested',
14+
'execution.started',
15+
'execution_started',
16+
'execution.completed',
17+
'execution_completed',
18+
'execution.failed',
19+
'execution_failed',
20+
'execution.timeout',
21+
'execution_timeout',
22+
'pod.created',
23+
'pod_created',
24+
'pod.running',
25+
'pod_running',
26+
'pod.succeeded',
27+
'pod_succeeded',
28+
'pod.failed',
29+
'pod_failed',
30+
'pod.terminated',
31+
'pod_terminated',
32+
])('renders SVG for "%s"', (eventType) => {
33+
const { container } = render(EventTypeIcon, { props: { eventType } });
34+
const svg = container.querySelector('svg');
35+
expect(svg).toBeInTheDocument();
36+
expect(svg?.classList.contains('lucide-help-circle')).toBe(false);
37+
});
38+
});
39+
40+
describe('unknown event type', () => {
41+
it('renders fallback icon for unknown type', () => {
42+
const { container } = render(EventTypeIcon, { props: { eventType: 'unknown.event' } });
43+
expect(container.querySelector('svg')).toBeInTheDocument();
44+
});
45+
46+
it('renders a different icon than known types', () => {
47+
const { container: unknownContainer } = render(EventTypeIcon, {
48+
props: { eventType: 'unknown.event' },
49+
});
50+
const { container: knownContainer } = render(EventTypeIcon, {
51+
props: { eventType: 'execution.started' },
52+
});
53+
expect(unknownContainer.querySelector('svg')?.innerHTML).not.toBe(
54+
knownContainer.querySelector('svg')?.innerHTML
55+
);
56+
});
57+
});
58+
59+
describe('size prop', () => {
60+
it.each([
61+
{ size: undefined, expected: '20', desc: 'defaults to 20' },
62+
{ size: 32, expected: '32', desc: 'passes custom size' },
63+
])('$desc', ({ size, expected }) => {
64+
const { container } = render(EventTypeIcon, {
65+
props: { eventType: 'execution.started', ...(size ? { size } : {}) },
66+
});
67+
const svg = container.querySelector('svg');
68+
expect(svg).toHaveAttribute('width', expected);
69+
expect(svg).toHaveAttribute('height', expected);
70+
});
71+
});
72+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/svelte';
3+
import { user } from '$test/test-utils';
4+
import ModalWrapper from './ModalWrapper.svelte';
5+
6+
describe('Modal', () => {
7+
beforeEach(() => {
8+
vi.clearAllMocks();
9+
});
10+
11+
describe('open/closed', () => {
12+
it.each([
13+
{ open: true, visible: true },
14+
{ open: false, visible: false },
15+
])('content visible=$visible when open=$open', ({ open, visible }) => {
16+
render(ModalWrapper, { props: { open } });
17+
if (visible) {
18+
expect(screen.getByTestId('modal-body')).toBeInTheDocument();
19+
} else {
20+
expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument();
21+
}
22+
});
23+
});
24+
25+
describe('accessibility', () => {
26+
it('has correct dialog a11y attributes', () => {
27+
render(ModalWrapper, { props: { open: true } });
28+
const dialog = screen.getByRole('dialog');
29+
expect(dialog).toHaveAttribute('aria-modal', 'true');
30+
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
31+
expect(screen.getByText('Test Modal')).toHaveAttribute('id', 'modal-title');
32+
expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument();
33+
});
34+
});
35+
36+
describe('close interactions', () => {
37+
it('fires onClose when X button clicked', async () => {
38+
const onClose = vi.fn();
39+
render(ModalWrapper, { props: { open: true, onClose } });
40+
await user.click(screen.getByRole('button', { name: 'Close modal' }));
41+
expect(onClose).toHaveBeenCalledOnce();
42+
});
43+
44+
it('fires onClose on Escape keydown', async () => {
45+
const onClose = vi.fn();
46+
render(ModalWrapper, { props: { open: true, onClose } });
47+
await fireEvent.keyDown(window, { key: 'Escape' });
48+
expect(onClose).toHaveBeenCalled();
49+
});
50+
51+
it('fires onClose on backdrop click', async () => {
52+
const onClose = vi.fn();
53+
render(ModalWrapper, { props: { open: true, onClose } });
54+
await fireEvent.click(screen.getByRole('dialog'));
55+
expect(onClose).toHaveBeenCalledOnce();
56+
});
57+
58+
it('does not fire onClose when clicking body content', async () => {
59+
const onClose = vi.fn();
60+
render(ModalWrapper, { props: { open: true, onClose } });
61+
await user.click(screen.getByTestId('modal-body'));
62+
expect(onClose).not.toHaveBeenCalled();
63+
});
64+
});
65+
66+
describe('size classes', () => {
67+
it.each([
68+
{ size: 'sm' as const, expectedClass: 'max-w-md' },
69+
{ size: 'md' as const, expectedClass: 'max-w-2xl' },
70+
{ size: 'lg' as const, expectedClass: 'max-w-4xl' },
71+
{ size: 'xl' as const, expectedClass: 'max-w-6xl' },
72+
])('applies $expectedClass for size=$size', ({ size, expectedClass }) => {
73+
const { container } = render(ModalWrapper, { props: { open: true, size } });
74+
expect(container.querySelector('.modal-container')?.classList.contains(expectedClass)).toBe(true);
75+
});
76+
77+
it('defaults to lg (max-w-4xl)', () => {
78+
const { container } = render(ModalWrapper, { props: { open: true } });
79+
expect(container.querySelector('.modal-container')?.classList.contains('max-w-4xl')).toBe(true);
80+
});
81+
});
82+
83+
describe('footer', () => {
84+
it.each([
85+
{ showFooter: true, hasFooter: true },
86+
{ showFooter: false, hasFooter: false },
87+
])('footer present=$hasFooter when showFooter=$showFooter', ({ showFooter, hasFooter }) => {
88+
const { container } = render(ModalWrapper, { props: { open: true, showFooter } });
89+
if (hasFooter) {
90+
expect(screen.getByTestId('modal-footer-content')).toBeInTheDocument();
91+
} else {
92+
expect(container.querySelector('.modal-footer')).not.toBeInTheDocument();
93+
}
94+
});
95+
});
96+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import Modal from '$components/Modal.svelte';
3+
4+
interface Props {
5+
open?: boolean;
6+
title?: string;
7+
onClose?: () => void;
8+
size?: 'sm' | 'md' | 'lg' | 'xl';
9+
showFooter?: boolean;
10+
}
11+
12+
let {
13+
open = false,
14+
title = 'Test Modal',
15+
onClose = () => {},
16+
size,
17+
showFooter = false,
18+
}: Props = $props();
19+
</script>
20+
21+
{#if showFooter}
22+
<Modal {open} {title} {onClose} {size}>
23+
<p data-testid="modal-body">Modal body content</p>
24+
{#snippet footer()}
25+
<div data-testid="modal-footer-content">Footer content</div>
26+
{/snippet}
27+
</Modal>
28+
{:else}
29+
<Modal {open} {title} {onClose} {size}>
30+
<p data-testid="modal-body">Modal body content</p>
31+
</Modal>
32+
{/if}

0 commit comments

Comments
 (0)