From 4d7838ce97f483813394c25f1b3cdaa1ce1ed89b Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Fri, 27 Feb 2026 21:01:23 +0100 Subject: [PATCH 1/9] test: extra frontend/unit tests --- .../__tests__/EventTypeIcon.test.ts | 72 ++++++++++++ .../src/components/__tests__/Modal.test.ts | 96 +++++++++++++++ .../components/__tests__/ModalWrapper.svelte | 32 +++++ .../components/__tests__/Pagination.test.ts | 109 +++++++++++++++++ .../admin/__tests__/ActionButtons.test.ts | 111 ++++++++++++++++++ .../admin/__tests__/FilterPanel.test.ts | 92 +++++++++++++++ .../admin/__tests__/FilterPanelWrapper.svelte | 29 +++++ .../admin/__tests__/StatsCard.test.ts | 75 ++++++++++++ .../admin/__tests__/StatusBadge.test.ts | 62 ++++++++++ .../editor/__tests__/LanguageSelect.test.ts | 102 ++++++++++++++++ 10 files changed, 780 insertions(+) create mode 100644 frontend/src/components/__tests__/EventTypeIcon.test.ts create mode 100644 frontend/src/components/__tests__/Modal.test.ts create mode 100644 frontend/src/components/__tests__/ModalWrapper.svelte create mode 100644 frontend/src/components/__tests__/Pagination.test.ts create mode 100644 frontend/src/components/admin/__tests__/ActionButtons.test.ts create mode 100644 frontend/src/components/admin/__tests__/FilterPanel.test.ts create mode 100644 frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte create mode 100644 frontend/src/components/admin/__tests__/StatsCard.test.ts create mode 100644 frontend/src/components/admin/__tests__/StatusBadge.test.ts diff --git a/frontend/src/components/__tests__/EventTypeIcon.test.ts b/frontend/src/components/__tests__/EventTypeIcon.test.ts new file mode 100644 index 00000000..78000c27 --- /dev/null +++ b/frontend/src/components/__tests__/EventTypeIcon.test.ts @@ -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(); + }); + + 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); + }); + }); +}); diff --git a/frontend/src/components/__tests__/Modal.test.ts b/frontend/src/components/__tests__/Modal.test.ts new file mode 100644 index 00000000..f1750f48 --- /dev/null +++ b/frontend/src/components/__tests__/Modal.test.ts @@ -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(); + }); + + 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(); + } + }); + }); +}); diff --git a/frontend/src/components/__tests__/ModalWrapper.svelte b/frontend/src/components/__tests__/ModalWrapper.svelte new file mode 100644 index 00000000..7d62800b --- /dev/null +++ b/frontend/src/components/__tests__/ModalWrapper.svelte @@ -0,0 +1,32 @@ + + +{#if showFooter} + +

Modal body content

+ {#snippet footer()} +
Footer content
+ {/snippet} +
+{:else} + +

Modal body content

+
+{/if} diff --git a/frontend/src/components/__tests__/Pagination.test.ts b/frontend/src/components/__tests__/Pagination.test.ts new file mode 100644 index 00000000..10a01e17 --- /dev/null +++ b/frontend/src/components/__tests__/Pagination.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import Pagination from '$components/Pagination.svelte'; + +const defaultProps = { + currentPage: 1, + totalPages: 5, + totalItems: 50, + pageSize: 10, + onPageChange: vi.fn(), +}; + +function renderPagination(overrides: Partial = {}) { + const props = { ...defaultProps, onPageChange: vi.fn(), ...overrides }; + return { ...render(Pagination, { props }), onPageChange: props.onPageChange }; +} + +describe('Pagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('page info text', () => { + it.each([ + { page: 1, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 1 - 10 of 50 items/ }, + { page: 3, totalPages: 5, totalItems: 50, pageSize: 10, expected: /Showing 21 - 30 of 50 items/ }, + { page: 3, totalPages: 3, totalItems: 23, pageSize: 10, expected: /Showing 21 - 23 of 23 items/ }, + ])('shows "$expected" for page $page of $totalPages ($totalItems items)', ({ page, totalPages, totalItems, pageSize, expected }) => { + renderPagination({ currentPage: page, totalPages, totalItems, pageSize }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('uses custom itemName', () => { + renderPagination({ itemName: 'events' } as Record); + expect(screen.getByText(/of 50 events/)).toBeInTheDocument(); + }); + }); + + describe('navigation buttons', () => { + it.each([ + { name: 'First page', expectedPage: 1 }, + { name: 'Previous page', expectedPage: 2 }, + { name: 'Next page', expectedPage: 4 }, + { name: 'Last page', expectedPage: 5 }, + ])('$name calls onPageChange($expectedPage)', async ({ name, expectedPage }) => { + const { onPageChange } = renderPagination({ currentPage: 3 }); + await user.click(screen.getByRole('button', { name })); + expect(onPageChange).toHaveBeenCalledWith(expectedPage); + }); + }); + + describe('disabled states', () => { + it.each([ + { page: 1, disabled: ['First page', 'Previous page'] }, + { page: 5, disabled: ['Next page', 'Last page'] }, + ])('disables $disabled on page $page', ({ page, disabled }) => { + renderPagination({ currentPage: page }); + for (const name of disabled) { + expect(screen.getByRole('button', { name })).toBeDisabled(); + } + }); + }); + + describe('page size selector', () => { + it('hidden when onPageSizeChange not provided', () => { + renderPagination(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('renders select with default options when onPageSizeChange provided', () => { + renderPagination({ onPageSizeChange: vi.fn() } as Record); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual([ + '10 / page', '25 / page', '50 / page', '100 / page', + ]); + }); + + it('fires onPageSizeChange on select change', async () => { + const onPageSizeChange = vi.fn(); + renderPagination({ onPageSizeChange } as Record); + await user.selectOptions(screen.getByRole('combobox'), '25'); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); + }); + + describe('visibility', () => { + it('renders nothing when totalPages=1 and no onPageSizeChange', () => { + const { container } = renderPagination({ totalPages: 1, totalItems: 5, pageSize: 10 }); + expect(container.querySelector('.pagination-container')).not.toBeInTheDocument(); + }); + + it('still shows when totalPages=1 if onPageSizeChange present', () => { + const { container } = renderPagination({ + totalPages: 1, totalItems: 5, pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(container.querySelector('.pagination-container')).toBeInTheDocument(); + }); + + it('hides nav buttons when totalPages=1', () => { + renderPagination({ + totalPages: 1, totalItems: 5, pageSize: 10, + onPageSizeChange: vi.fn(), + } as Record); + expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/ActionButtons.test.ts b/frontend/src/components/admin/__tests__/ActionButtons.test.ts new file mode 100644 index 00000000..86585064 --- /dev/null +++ b/frontend/src/components/admin/__tests__/ActionButtons.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import type { Component } from 'svelte'; +import ActionButtons from '../ActionButtons.svelte'; + +const MockIcon: Component = (() => ({})) as unknown as Component; + +function makeAction(overrides: Record = {}) { + return { + icon: MockIcon, + label: 'Test Action', + onclick: vi.fn(), + ...overrides, + }; +} + +describe('ActionButtons', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('variants', () => { + it('icon-only (default): renders button without visible label, with title', () => { + render(ActionButtons, { props: { actions: [makeAction()] } }); + const btn = screen.getByRole('button'); + expect(btn).not.toHaveTextContent('Test Action'); + expect(btn).toHaveAttribute('title', 'Test Action'); + }); + + it.each([ + { variant: 'with-text' as const, label: 'with-text' }, + { variant: 'mobile' as const, label: 'mobile' }, + ])('$label: renders button with visible label text', ({ variant }) => { + render(ActionButtons, { + props: { actions: [makeAction()], variant }, + }); + expect(screen.getByRole('button')).toHaveTextContent('Test Action'); + }); + + it('mobile: buttons have flex-1 for full width', () => { + render(ActionButtons, { + props: { actions: [makeAction()], variant: 'mobile' }, + }); + expect(screen.getByRole('button').classList.contains('flex-1')).toBe(true); + }); + }); + + describe('color classes', () => { + it.each([ + { color: 'primary', expectedClass: 'text-primary' }, + { color: 'success', expectedClass: 'text-green-600' }, + { color: 'danger', expectedClass: 'text-red-600' }, + { color: 'warning', expectedClass: 'text-yellow-600' }, + { color: 'info', expectedClass: 'text-blue-600' }, + { color: undefined, expectedClass: 'text-fg-muted' }, + ])('applies $expectedClass for color=$color', ({ color, expectedClass }) => { + render(ActionButtons, { + props: { actions: [makeAction({ color })] }, + }); + expect(screen.getByRole('button').className).toContain(expectedClass); + }); + }); + + describe('disabled state', () => { + it('sets disabled attribute and opacity class', () => { + render(ActionButtons, { + props: { actions: [makeAction({ disabled: true })] }, + }); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + expect(btn.className).toContain('opacity-50'); + }); + + it('does not fire onclick when disabled', async () => { + const onclick = vi.fn(); + render(ActionButtons, { + props: { actions: [makeAction({ disabled: true, onclick })] }, + }); + await user.click(screen.getByRole('button')); + expect(onclick).not.toHaveBeenCalled(); + }); + }); + + it('fires onclick when clicked', async () => { + const onclick = vi.fn(); + render(ActionButtons, { + props: { actions: [makeAction({ onclick })] }, + }); + await user.click(screen.getByRole('button')); + expect(onclick).toHaveBeenCalledOnce(); + }); + + describe('title attribute', () => { + it.each([ + { title: 'Custom Title', expected: 'Custom Title', desc: 'uses explicit title' }, + { title: undefined, expected: 'Test Action', desc: 'falls back to label' }, + ])('$desc', ({ title, expected }) => { + render(ActionButtons, { + props: { actions: [makeAction({ title })] }, + }); + expect(screen.getByRole('button')).toHaveAttribute('title', expected); + }); + }); + + it('renders N buttons for N actions', () => { + const actions = [makeAction({ label: 'A' }), makeAction({ label: 'B' }), makeAction({ label: 'C' })]; + render(ActionButtons, { props: { actions } }); + expect(screen.getAllByRole('button')).toHaveLength(3); + }); +}); diff --git a/frontend/src/components/admin/__tests__/FilterPanel.test.ts b/frontend/src/components/admin/__tests__/FilterPanel.test.ts new file mode 100644 index 00000000..ee94a957 --- /dev/null +++ b/frontend/src/components/admin/__tests__/FilterPanel.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { user } from '$test/test-utils'; +import FilterPanelWrapper from './FilterPanelWrapper.svelte'; + +describe('FilterPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('toggle button', () => { + it('renders toggle button by default', () => { + render(FilterPanelWrapper); + expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + }); + + it('hides toggle button when showToggleButton=false', () => { + render(FilterPanelWrapper, { props: { showToggleButton: false } }); + expect(screen.queryByRole('button', { name: /Filters/i })).not.toBeInTheDocument(); + }); + + it('flips open and fires onToggle on click', async () => { + const onToggle = vi.fn(); + render(FilterPanelWrapper, { props: { onToggle } }); + await user.click(screen.getByRole('button', { name: /Filters/i })); + expect(onToggle).toHaveBeenCalledOnce(); + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + }); + }); + + describe('active filter badge', () => { + it.each([ + { hasActiveFilters: true, visible: true }, + { hasActiveFilters: false, visible: false }, + ])('badge visible=$visible when hasActiveFilters=$hasActiveFilters', ({ hasActiveFilters, visible }) => { + render(FilterPanelWrapper, { + props: { hasActiveFilters, activeFilterCount: 3 }, + }); + if (visible) { + expect(screen.getByText('3')).toBeInTheDocument(); + } else { + expect(screen.queryByText('3')).not.toBeInTheDocument(); + } + }); + }); + + describe('panel content', () => { + it.each([ + { open: true, visible: true }, + { open: false, visible: false }, + ])('children visible=$visible when open=$open', ({ open, visible }) => { + render(FilterPanelWrapper, { props: { open } }); + if (visible) { + expect(screen.getByTestId('filter-content')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('filter-content')).not.toBeInTheDocument(); + } + }); + }); + + describe('title', () => { + it.each([ + { title: undefined, expected: 'Filter' }, + { title: 'Advanced', expected: 'Advanced' }, + ])('renders "$expected" when title=$title', ({ title, expected }) => { + render(FilterPanelWrapper, { props: { open: true, title } }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + }); + + describe('action buttons', () => { + it.each([ + { button: 'Clear All', callbackProp: 'onClear' }, + { button: 'Apply', callbackProp: 'onApply' }, + ] as const)('$button: visible only when $callbackProp provided, fires callback', async ({ button, callbackProp }) => { + // Hidden when no callback + render(FilterPanelWrapper, { props: { open: true } }); + expect(screen.queryByRole('button', { name: new RegExp(button, 'i') })).not.toBeInTheDocument(); + + // Visible and fires callback + const callback = vi.fn(); + const { unmount } = render(FilterPanelWrapper, { + props: { open: true, [callbackProp]: callback }, + }); + const btn = screen.getByRole('button', { name: new RegExp(button, 'i') }); + expect(btn).toBeInTheDocument(); + await user.click(btn); + expect(callback).toHaveBeenCalledOnce(); + unmount(); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte new file mode 100644 index 00000000..9aac5701 --- /dev/null +++ b/frontend/src/components/admin/__tests__/FilterPanelWrapper.svelte @@ -0,0 +1,29 @@ + + + +
Filter slot
+
diff --git a/frontend/src/components/admin/__tests__/StatsCard.test.ts b/frontend/src/components/admin/__tests__/StatsCard.test.ts new file mode 100644 index 00000000..6c335ca3 --- /dev/null +++ b/frontend/src/components/admin/__tests__/StatsCard.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import type { Component } from 'svelte'; +import StatsCard from '../StatsCard.svelte'; + +const MockIcon: Component = (() => ({})) as unknown as Component; + +describe('StatsCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { label: 'Total', value: 42, desc: 'numeric' }, + { label: 'Status', value: 'Active', desc: 'string' }, + ])('renders label and $desc value', ({ label, value }) => { + render(StatsCard, { props: { label, value } }); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText(String(value))).toBeInTheDocument(); + }); + + describe('sublabel', () => { + it('shows sublabel when provided', () => { + render(StatsCard, { props: { label: 'Total', value: 42, sublabel: '+5 today' } }); + expect(screen.getByText('+5 today')).toBeInTheDocument(); + }); + + it('does not render sublabel when absent', () => { + const { container } = render(StatsCard, { props: { label: 'Total', value: 42 } }); + expect(container.querySelectorAll('p')).toHaveLength(2); + }); + }); + + describe('icon', () => { + it.each([ + { icon: MockIcon, pCount: 2, desc: 'renders without error when provided' }, + { icon: undefined, pCount: 2, desc: 'renders only label/value when absent' }, + ])('$desc', ({ icon, pCount }) => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, ...(icon ? { icon } : {}) }, + }); + expect(screen.getByText('Total')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(container.querySelectorAll('p').length).toBeGreaterThanOrEqual(pCount); + }); + }); + + describe('compact mode', () => { + it.each([ + { compact: true, selector: '.p-2' }, + { compact: false, selector: '.p-4' }, + ])('applies $selector padding when compact=$compact', ({ compact, selector }) => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, compact }, + }); + expect(container.querySelector(selector)).toBeInTheDocument(); + }); + }); + + describe('custom colors', () => { + it('applies bgColor class', () => { + const { container } = render(StatsCard, { + props: { label: 'Total', value: 42, bgColor: 'bg-green-100' }, + }); + expect(container.firstElementChild?.classList.contains('bg-green-100')).toBe(true); + }); + + it('applies textColor to value', () => { + render(StatsCard, { + props: { label: 'Total', value: 42, textColor: 'text-red-500' }, + }); + expect(screen.getByText('42').classList.contains('text-red-500')).toBe(true); + }); + }); +}); diff --git a/frontend/src/components/admin/__tests__/StatusBadge.test.ts b/frontend/src/components/admin/__tests__/StatusBadge.test.ts new file mode 100644 index 00000000..9c3697b4 --- /dev/null +++ b/frontend/src/components/admin/__tests__/StatusBadge.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import StatusBadge from '../StatusBadge.svelte'; + +describe('StatusBadge', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders status text', () => { + render(StatusBadge, { props: { status: 'Running' } }); + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + describe('color', () => { + it('defaults to badge-neutral', () => { + render(StatusBadge, { props: { status: 'Test' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('badge-neutral')).toBe(true); + }); + + it('applies custom color class', () => { + render(StatusBadge, { props: { status: 'Test', color: 'badge-success' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('badge-success')).toBe(true); + }); + }); + + describe('size', () => { + it.each([ + { size: 'sm' as const, expectedClass: 'text-xs' }, + { size: 'md' as const, expectedClass: 'text-sm' }, + { size: 'lg' as const, expectedClass: 'text-base' }, + ])('applies $expectedClass for size=$size', ({ size, expectedClass }) => { + render(StatusBadge, { props: { status: 'Test', size } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains(expectedClass)).toBe(true); + }); + + it('defaults to md (text-sm)', () => { + render(StatusBadge, { props: { status: 'Test' } }); + const badge = screen.getByText('Test'); + expect(badge.classList.contains('text-sm')).toBe(true); + }); + }); + + describe('suffix', () => { + it('shows suffix with ml-1 span when provided', () => { + render(StatusBadge, { props: { status: 'Running', suffix: '(3s)' } }); + expect(screen.getByText('(3s)')).toBeInTheDocument(); + const suffixEl = screen.getByText('(3s)'); + expect(suffixEl.classList.contains('ml-1')).toBe(true); + expect(suffixEl.tagName.toLowerCase()).toBe('span'); + }); + + it('does not render suffix when absent', () => { + const { container } = render(StatusBadge, { props: { status: 'Running' } }); + const badge = container.querySelector('.badge'); + expect(badge?.querySelectorAll('span')).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts index c96b122f..c3a45ac0 100644 --- a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -98,6 +98,7 @@ describe('LanguageSelect', () => { it.each([ { key: '{ArrowDown}', label: 'ArrowDown' }, { key: '{Enter}', label: 'Enter' }, + { key: ' ', label: 'Space' }, ])('opens menu with $label on trigger', async ({ key }) => { renderSelect(); screen.getByRole('button', { name: /Select language/i }).focus(); @@ -114,4 +115,105 @@ describe('LanguageSelect', () => { }); }); }); + + describe('menu keyboard navigation', () => { + async function openMenuAndGetMenu() { + const result = renderSelect(); + await user.click(screen.getByRole('button', { name: /Select language/i })); + const menu = screen.getByRole('menu', { name: 'Select language and version' }); + return { ...result, menu }; + } + + it.each([ + { key: 'ArrowDown', presses: 1, expectedIndex: 1, desc: 'moves focus to next language' }, + { key: 'ArrowDown', presses: 3, expectedIndex: 2, desc: 'clamps at last language' }, + { key: 'ArrowUp', presses: 1, expectedIndex: 0, desc: 'clamps at first language' }, + ])('$key $desc', async ({ key, presses, expectedIndex }) => { + const { menu } = await openMenuAndGetMenu(); + for (let i = 0; i < presses; i++) { + await fireEvent.keyDown(menu, { key }); + } + const items = screen.getAllByRole('menuitem'); + expect(items[expectedIndex]).toHaveAttribute('tabindex', '0'); + }); + + it('ArrowUp moves focus to previous language', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // index 1 + await fireEvent.keyDown(menu, { key: 'ArrowUp' }); // back to 0 + const items = screen.getAllByRole('menuitem'); + expect(items[0]).toHaveAttribute('tabindex', '0'); + }); + + it.each([ + { key: 'ArrowRight', label: 'ArrowRight' }, + { key: 'Enter', label: 'Enter' }, + { key: ' ', label: 'Space' }, + ])('$label on language opens version submenu', async ({ key }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key }); + expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); + }); + + it.each([ + { key: 'ArrowDown', presses: 1, expectedIndex: 1, desc: 'moves to next version' }, + { key: 'ArrowDown', presses: 3, expectedIndex: 2, desc: 'clamps at last version' }, + { key: 'ArrowUp', presses: 1, expectedIndex: 0, desc: 'clamps at first version' }, + ])('$key in submenu $desc', async ({ key, presses, expectedIndex }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + for (let i = 0; i < presses; i++) { + await fireEvent.keyDown(menu, { key }); + } + const versionMenu = screen.getByRole('menu', { name: /python versions/i }); + const versions = within(versionMenu).getAllByRole('menuitemradio'); + expect(versions[expectedIndex]).toHaveAttribute('tabindex', '0'); + }); + + it('ArrowUp in submenu moves to previous version', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // index 1 + await fireEvent.keyDown(menu, { key: 'ArrowUp' }); // back to 0 + const versionMenu = screen.getByRole('menu', { name: /python versions/i }); + const versions = within(versionMenu).getAllByRole('menuitemradio'); + expect(versions[0]).toHaveAttribute('tabindex', '0'); + }); + + it.each([ + { key: 'Enter', label: 'Enter' }, + { key: ' ', label: 'Space' }, + ])('$label in submenu selects version and closes menu', async ({ key }) => { + const { onselect, menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + await fireEvent.keyDown(menu, { key: 'ArrowDown' }); // move to version index 1 (3.10) + await fireEvent.keyDown(menu, { key }); + expect(onselect).toHaveBeenCalledWith('python', '3.10'); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + + it.each([ + { key: 'ArrowLeft', label: 'ArrowLeft' }, + { key: 'Escape', label: 'Escape' }, + ])('$label in submenu exits submenu but keeps main menu', async ({ key }) => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'ArrowRight' }); // open python submenu + expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); + await fireEvent.keyDown(menu, { key }); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: /python versions/i })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('menu', { name: 'Select language and version' })).toBeInTheDocument(); + }); + + it('Escape in root menu closes entire menu', async () => { + const { menu } = await openMenuAndGetMenu(); + await fireEvent.keyDown(menu, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + }); }); From 7e49b16f15724b7115a203848020c3aab81b5d39 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 28 Feb 2026 00:09:40 +0100 Subject: [PATCH 2/9] fix: quicker frontend/unit + consistency in imports (all use aliases instead of relative paths) --- frontend/src/__tests__/test-utils.ts | 5 +++- .../admin/__tests__/AdminEvents.test.ts | 1 - .../admin/__tests__/AdminExecutions.test.ts | 4 --- .../routes/admin/__tests__/AdminSagas.test.ts | 4 --- .../routes/admin/__tests__/AdminUsers.test.ts | 5 ---- frontend/vitest.config.ts | 14 ++------- frontend/vitest.setup.ts | 29 +++++++------------ 7 files changed, 17 insertions(+), 45 deletions(-) diff --git a/frontend/src/__tests__/test-utils.ts b/frontend/src/__tests__/test-utils.ts index 3392826d..f93b92ad 100644 --- a/frontend/src/__tests__/test-utils.ts +++ b/frontend/src/__tests__/test-utils.ts @@ -22,7 +22,10 @@ import type { export type UserEventInstance = ReturnType; -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) diff --git a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts index c403d4d1..08aba90e 100644 --- a/frontend/src/routes/admin/__tests__/AdminEvents.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminEvents.test.ts @@ -63,7 +63,6 @@ async function renderWithEvents(events = createMockEvents(5), stats = createMock mocks.getEventStatsApiV1AdminEventsStatsGet.mockResolvedValue({ data: stats, error: null }); const result = render(AdminEvents); - await tick(); await waitFor(() => expect(mocks.browseEventsApiV1AdminEventsBrowsePost).toHaveBeenCalled()); return result; } diff --git a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts index 7b77381e..f8add282 100644 --- a/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminExecutions.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; -import { tick } from 'svelte'; import { user } from '$test/test-utils'; interface MockExecutionOverrides { @@ -102,7 +101,6 @@ async function renderWithExecutions( }); const result = render(AdminExecutions); - await tick(); await waitFor(() => expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled()); return result; } @@ -128,7 +126,6 @@ describe('AdminExecutions', () => { describe('initial loading', () => { it('calls API on mount', async () => { render(AdminExecutions); - await tick(); await waitFor(() => { expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled(); expect(mocks.getQueueStatusApiV1AdminExecutionsQueueGet).toHaveBeenCalled(); @@ -340,7 +337,6 @@ describe('AdminExecutions', () => { }); render(AdminExecutions); - await tick(); await waitFor(() => expect(mocks.listExecutionsApiV1AdminExecutionsGet).toHaveBeenCalled()); expect(screen.getByText(/showing/i)).toBeInTheDocument(); diff --git a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts index c281b141..088408ee 100644 --- a/frontend/src/routes/admin/__tests__/AdminSagas.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminSagas.test.ts @@ -73,7 +73,6 @@ async function renderWithSagas(sagas = createMockSagas(5)) { }); const result = render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); return result; } @@ -89,7 +88,6 @@ describe('AdminSagas', () => { describe('initial loading', () => { it('calls listSagas on mount', async () => { render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); }); @@ -380,7 +378,6 @@ describe('AdminSagas', () => { }); render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); expect(screen.getByText(/showing/i)).toBeInTheDocument(); @@ -394,7 +391,6 @@ describe('AdminSagas', () => { }); render(AdminSagas); - await tick(); await waitFor(() => expect(mocks.listSagasApiV1SagasGet).toHaveBeenCalled()); vi.clearAllMocks(); diff --git a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts index d5d0ea1a..b2f1d359 100644 --- a/frontend/src/routes/admin/__tests__/AdminUsers.test.ts +++ b/frontend/src/routes/admin/__tests__/AdminUsers.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/svelte'; -import { tick } from 'svelte'; import { user } from '$test/test-utils'; interface MockUserOverrides { @@ -93,7 +92,6 @@ import AdminUsers from '$routes/admin/AdminUsers.svelte'; async function renderWithUsers(users = createMockUsers(3)) { mocks.listUsersApiV1AdminUsersGet.mockResolvedValue({ data: { users, total: users.length }, error: null }); const result = render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); return result; } @@ -179,7 +177,6 @@ describe('AdminUsers', () => { return { data: { users: filtered, total: filtered.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const searchInput = screen.getByPlaceholderText(/Search by username/i); @@ -201,7 +198,6 @@ describe('AdminUsers', () => { return { data: { users: filtered, total: filtered.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const roleSelect = screen.getByRole('combobox', { name: /Role/i }); @@ -236,7 +232,6 @@ describe('AdminUsers', () => { return { data: { users, total: users.length }, error: null }; }); render(AdminUsers); - await tick(); await waitFor(() => expect(mocks.listUsersApiV1AdminUsersGet).toHaveBeenCalled()); const searchInput = screen.getByPlaceholderText(/Search by username/i); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 656c4413..457ab66f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,24 +10,16 @@ export default defineConfig({ svelteTesting(), ], test: { - environment: 'jsdom', pool: 'threads', maxWorkers: 8, minWorkers: 2, isolate: false, css: false, + testTimeout: 10_000, + environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.{test,spec}.{js,ts}'], - globals: true, - testTimeout: 10_000, - fakeTimers: { - shouldAdvanceTime: true, - }, - environmentMatchGlobs: [ - ['src/lib/**/*.test.ts', 'node'], - ['src/stores/**/*.test.ts', 'node'], - ['src/utils/**/*.test.ts', 'node'], - ], + fakeTimers: { shouldAdvanceTime: true }, deps: { optimizer: { web: { diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 8d4982fa..c63ef93b 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -2,6 +2,8 @@ import '@testing-library/jest-dom/vitest'; import { vi, beforeEach } from 'vitest'; import { cleanup } from '@testing-library/svelte'; +vi.useFakeTimers(); + // Global handler for promise rejections (mirrors main.ts behavior) // API errors are handled by interceptor - just silence the rejection in tests process.on('unhandledRejection', () => {}); @@ -72,26 +74,8 @@ vi.stubGlobal('IntersectionObserver', vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), }))); -// Enable fake timers globally (shouldAdvanceTime configured in vitest.config.ts) -vi.useFakeTimers(); - -// Reset DOM and storage between every test (required for isolate: false) -beforeEach(() => { - cleanup(); - Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); - Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); -}); - -// Helper to reset mocks between tests (legacy export, kept for compatibility) -export function resetStorageMocks() { - Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); - Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); - vi.clearAllMocks(); -} - // Mock Element.prototype.animate for Svelte transitions (canonical global stub) -// Guarded for node environment where Element is not available -if (typeof Element !== 'undefined') Element.prototype.animate = vi.fn().mockImplementation(() => { +Element.prototype.animate = vi.fn().mockImplementation(() => { const mock = { _onfinish: null as (() => void) | null, get onfinish() { return this._onfinish; }, @@ -113,3 +97,10 @@ if (typeof Element !== 'undefined') Element.prototype.animate = vi.fn().mockImpl }; return mock as unknown as Animation; }); + +// Reset storage and DOM between every test (required for isolate: false) +beforeEach(() => { + Object.keys(localStorageStore).forEach(key => delete localStorageStore[key]); + Object.keys(sessionStorageStore).forEach(key => delete sessionStorageStore[key]); + cleanup(); +}); From b3ff7e8559062b028298d6d65aecfb0a669a2d76 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 28 Feb 2026 00:10:04 +0100 Subject: [PATCH 3/9] fix: quicker frontend/unit + consistency in imports (all use aliases instead of relative paths) --- frontend/src/components/__tests__/ModalWrapper.svelte | 2 +- frontend/src/components/admin/__tests__/ActionButtons.test.ts | 2 +- .../src/components/admin/__tests__/FilterPanelWrapper.svelte | 2 +- frontend/src/components/admin/__tests__/StatsCard.test.ts | 2 +- frontend/src/components/admin/__tests__/StatusBadge.test.ts | 2 +- .../components/admin/events/__tests__/EventDetailsModal.test.ts | 2 +- .../src/components/admin/events/__tests__/EventFilters.test.ts | 2 +- .../components/admin/events/__tests__/EventStatsCards.test.ts | 2 +- .../src/components/admin/events/__tests__/EventsTable.test.ts | 2 +- .../admin/events/__tests__/ReplayPreviewModal.test.ts | 2 +- .../admin/events/__tests__/ReplayProgressBanner.test.ts | 2 +- .../components/admin/events/__tests__/UserOverviewModal.test.ts | 2 +- .../src/components/editor/__tests__/CodeMirrorEditor.test.ts | 2 +- frontend/src/components/editor/__tests__/LanguageSelect.test.ts | 2 +- frontend/src/components/editor/__tests__/OutputPanel.test.ts | 2 +- frontend/src/components/editor/__tests__/ResourceLimits.test.ts | 2 +- frontend/src/components/editor/__tests__/SavedScripts.test.ts | 2 +- frontend/src/lib/__tests__/api-interceptors.test.ts | 2 +- frontend/src/lib/__tests__/formatters.test.ts | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/__tests__/ModalWrapper.svelte b/frontend/src/components/__tests__/ModalWrapper.svelte index 7d62800b..634cdf1e 100644 --- a/frontend/src/components/__tests__/ModalWrapper.svelte +++ b/frontend/src/components/__tests__/ModalWrapper.svelte @@ -1,5 +1,5 @@