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
127 changes: 126 additions & 1 deletion frontend/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ import type {
NotificationResponse,
EventMetadata,
EventType,
AdminExecutionResponse,
QueueStatusResponse,
SagaStatusResponse,
UserResponse,
} from '$lib/api';

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

export const user: UserEventInstance = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
export const user: UserEventInstance = userEvent.setup({
delay: null,
pointerEventsCheck: 0,
});

// ============================================================================
// Mock Store Type (for use with vi.hoisted)
Expand Down Expand Up @@ -236,3 +243,121 @@ export function createMockUserOverview(): AdminUserOverview {
recent_events: [createMockEvent() as AdminUserOverview['recent_events'][number]],
};
}

// ============================================================================
// Admin Execution Mock Helpers
// ============================================================================

export const DEFAULT_EXECUTION: AdminExecutionResponse = {
execution_id: 'exec-1',
script: 'print("hi")',
status: 'queued',
lang: 'python',
lang_version: '3.11',
priority: 'normal',
user_id: 'user-1',
stdout: null,
stderr: null,
exit_code: null,
error_type: null,
created_at: '2024-01-15T10:30:00Z',
updated_at: '2024-01-15T10:30:00Z',
};

export const createMockExecution = (overrides: Partial<AdminExecutionResponse> = {}): AdminExecutionResponse => ({
...DEFAULT_EXECUTION,
...overrides,
});

const EXECUTION_STATUSES: AdminExecutionResponse['status'][] = [
'queued', 'scheduled', 'running', 'completed', 'failed', 'timeout', 'cancelled', 'error',
];
const EXECUTION_PRIORITIES: AdminExecutionResponse['priority'][] = [
'critical', 'high', 'normal', 'low', 'background',
];

export const createMockExecutions = (count: number): AdminExecutionResponse[] =>
Array.from({ length: count }, (_, i) => createMockExecution({
execution_id: `exec-${i + 1}`,
status: EXECUTION_STATUSES[i % EXECUTION_STATUSES.length],
priority: EXECUTION_PRIORITIES[i % EXECUTION_PRIORITIES.length],
user_id: `user-${(i % 3) + 1}`,
created_at: new Date(Date.now() - i * 60000).toISOString(),
}));

export const createMockQueueStatus = (overrides: Partial<QueueStatusResponse> = {}): QueueStatusResponse => ({
queue_depth: 5,
active_count: 2,
max_concurrent: 10,
by_priority: { normal: 3, high: 2 },
...overrides,
});

// ============================================================================
// Admin Saga Mock Helpers
// ============================================================================

export const DEFAULT_SAGA: SagaStatusResponse = {
saga_id: 'saga-1',
saga_name: 'execution_saga',
execution_id: 'exec-123',
state: 'running',
current_step: 'create_pod',
completed_steps: ['validate_execution', 'allocate_resources', 'queue_execution'],
compensated_steps: [],
retry_count: 0,
error_message: null,
created_at: '2024-01-15T10:30:00Z',
updated_at: '2024-01-15T10:31:00Z',
completed_at: null,
};

export const createMockSaga = (overrides: Partial<SagaStatusResponse> = {}): SagaStatusResponse => ({
...DEFAULT_SAGA,
...overrides,
});

const SAGA_STATES: SagaStatusResponse['state'][] = [
'created', 'running', 'completed', 'failed', 'compensating', 'timeout',
];

export const createMockSagas = (count: number): SagaStatusResponse[] =>
Array.from({ length: count }, (_, i) => createMockSaga({
saga_id: `saga-${i + 1}`,
execution_id: `exec-${i + 1}`,
state: SAGA_STATES[i % SAGA_STATES.length],
created_at: new Date(Date.now() - i * 60000).toISOString(),
updated_at: new Date(Date.now() - i * 30000).toISOString(),
}));

// ============================================================================
// Admin User Mock Helpers
// ============================================================================

export const DEFAULT_USER: UserResponse = {
user_id: 'user-1',
username: 'testuser',
email: 'test@example.com',
role: 'user',
is_active: true,
is_superuser: false,
created_at: '2024-01-15T10:30:00Z',
updated_at: '2024-01-15T10:30:00Z',
bypass_rate_limit: false,
global_multiplier: 1.0,
has_custom_limits: false,
};

export const createMockUser = (overrides: Partial<UserResponse> = {}): UserResponse => ({
...DEFAULT_USER,
...overrides,
});

export const createMockUsers = (count: number): UserResponse[] =>
Array.from({ length: count }, (_, i) => createMockUser({
user_id: `user-${i + 1}`,
username: `user${i + 1}`,
email: `user${i + 1}@example.com`,
role: i === 0 ? 'admin' : 'user',
is_active: i % 3 !== 0,
}));
72 changes: 72 additions & 0 deletions frontend/src/components/__tests__/EventTypeIcon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '@testing-library/svelte';
import EventTypeIcon from '$components/EventTypeIcon.svelte';

describe('EventTypeIcon', () => {
beforeEach(() => {
vi.clearAllMocks();
});
Comment thread
HardMax71 marked this conversation as resolved.

describe('known event types', () => {
it.each([
'execution.requested',
'execution_requested',
'execution.started',
'execution_started',
'execution.completed',
'execution_completed',
'execution.failed',
'execution_failed',
'execution.timeout',
'execution_timeout',
'pod.created',
'pod_created',
'pod.running',
'pod_running',
'pod.succeeded',
'pod_succeeded',
'pod.failed',
'pod_failed',
'pod.terminated',
'pod_terminated',
])('renders SVG for "%s"', (eventType) => {
const { container } = render(EventTypeIcon, { props: { eventType } });
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg?.classList.contains('lucide-help-circle')).toBe(false);
});
});

describe('unknown event type', () => {
it('renders fallback icon for unknown type', () => {
const { container } = render(EventTypeIcon, { props: { eventType: 'unknown.event' } });
expect(container.querySelector('svg')).toBeInTheDocument();
});

it('renders a different icon than known types', () => {
const { container: unknownContainer } = render(EventTypeIcon, {
props: { eventType: 'unknown.event' },
});
const { container: knownContainer } = render(EventTypeIcon, {
props: { eventType: 'execution.started' },
});
expect(unknownContainer.querySelector('svg')?.innerHTML).not.toBe(
knownContainer.querySelector('svg')?.innerHTML
);
});
});

describe('size prop', () => {
it.each([
{ size: undefined, expected: '20', desc: 'defaults to 20' },
{ size: 32, expected: '32', desc: 'passes custom size' },
])('$desc', ({ size, expected }) => {
const { container } = render(EventTypeIcon, {
props: { eventType: 'execution.started', ...(size ? { size } : {}) },
});
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', expected);
expect(svg).toHaveAttribute('height', expected);
});
});
});
96 changes: 96 additions & 0 deletions frontend/src/components/__tests__/Modal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { user } from '$test/test-utils';
import ModalWrapper from './ModalWrapper.svelte';

describe('Modal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
Comment thread
HardMax71 marked this conversation as resolved.

describe('open/closed', () => {
it.each([
{ open: true, visible: true },
{ open: false, visible: false },
])('content visible=$visible when open=$open', ({ open, visible }) => {
render(ModalWrapper, { props: { open } });
if (visible) {
expect(screen.getByTestId('modal-body')).toBeInTheDocument();
} else {
expect(screen.queryByTestId('modal-body')).not.toBeInTheDocument();
}
});
});

describe('accessibility', () => {
it('has correct dialog a11y attributes', () => {
render(ModalWrapper, { props: { open: true } });
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
expect(screen.getByText('Test Modal')).toHaveAttribute('id', 'modal-title');
expect(screen.getByRole('button', { name: 'Close modal' })).toBeInTheDocument();
});
});

describe('close interactions', () => {
it('fires onClose when X button clicked', async () => {
const onClose = vi.fn();
render(ModalWrapper, { props: { open: true, onClose } });
await user.click(screen.getByRole('button', { name: 'Close modal' }));
expect(onClose).toHaveBeenCalledOnce();
});

it('fires onClose on Escape keydown', async () => {
const onClose = vi.fn();
render(ModalWrapper, { props: { open: true, onClose } });
await fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});

it('fires onClose on backdrop click', async () => {
const onClose = vi.fn();
render(ModalWrapper, { props: { open: true, onClose } });
await fireEvent.click(screen.getByRole('dialog'));
expect(onClose).toHaveBeenCalledOnce();
});

it('does not fire onClose when clicking body content', async () => {
const onClose = vi.fn();
render(ModalWrapper, { props: { open: true, onClose } });
await user.click(screen.getByTestId('modal-body'));
expect(onClose).not.toHaveBeenCalled();
});
});

describe('size classes', () => {
it.each([
{ size: 'sm' as const, expectedClass: 'max-w-md' },
{ size: 'md' as const, expectedClass: 'max-w-2xl' },
{ size: 'lg' as const, expectedClass: 'max-w-4xl' },
{ size: 'xl' as const, expectedClass: 'max-w-6xl' },
])('applies $expectedClass for size=$size', ({ size, expectedClass }) => {
const { container } = render(ModalWrapper, { props: { open: true, size } });
expect(container.querySelector('.modal-container')?.classList.contains(expectedClass)).toBe(true);
});

it('defaults to lg (max-w-4xl)', () => {
const { container } = render(ModalWrapper, { props: { open: true } });
expect(container.querySelector('.modal-container')?.classList.contains('max-w-4xl')).toBe(true);
});
});

describe('footer', () => {
it.each([
{ showFooter: true, hasFooter: true },
{ showFooter: false, hasFooter: false },
])('footer present=$hasFooter when showFooter=$showFooter', ({ showFooter, hasFooter }) => {
const { container } = render(ModalWrapper, { props: { open: true, showFooter } });
if (hasFooter) {
expect(screen.getByTestId('modal-footer-content')).toBeInTheDocument();
} else {
expect(container.querySelector('.modal-footer')).not.toBeInTheDocument();
}
});
});
});
32 changes: 32 additions & 0 deletions frontend/src/components/__tests__/ModalWrapper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import Modal from '$components/Modal.svelte';

interface Props {
open?: boolean;
title?: string;
onClose?: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl';
showFooter?: boolean;
}

let {
open = false,
title = 'Test Modal',
onClose = () => {},
size,
showFooter = false,
}: Props = $props();
</script>

{#if showFooter}
<Modal {open} {title} {onClose} {size}>
<p data-testid="modal-body">Modal body content</p>
{#snippet footer()}
<div data-testid="modal-footer-content">Footer content</div>
{/snippet}
</Modal>
{:else}
<Modal {open} {title} {onClose} {size}>
<p data-testid="modal-body">Modal body content</p>
</Modal>
{/if}
Loading
Loading