From 80661c39e9890624b2a79fa5f6f2a79c4b4b739a Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 7 Feb 2026 21:56:00 +0100 Subject: [PATCH] test: more tests for editor component --- .../editor/__tests__/CodeMirrorEditor.test.ts | 167 +++++++++++++++ .../editor/__tests__/LanguageSelect.test.ts | 125 +++++++++++ .../editor/__tests__/OutputPanel.test.ts | 202 ++++++++++++++++++ .../editor/__tests__/ResourceLimits.test.ts | 70 ++++++ .../editor/__tests__/SavedScripts.test.ts | 109 ++++++++++ 5 files changed, 673 insertions(+) create mode 100644 frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts create mode 100644 frontend/src/components/editor/__tests__/LanguageSelect.test.ts create mode 100644 frontend/src/components/editor/__tests__/OutputPanel.test.ts create mode 100644 frontend/src/components/editor/__tests__/ResourceLimits.test.ts create mode 100644 frontend/src/components/editor/__tests__/SavedScripts.test.ts diff --git a/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts b/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts new file mode 100644 index 00000000..ba17f832 --- /dev/null +++ b/frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/svelte'; +import type { EditorSettings } from '$lib/api'; + +const mocks = vi.hoisted(() => { + const dispatchFn = vi.fn(); + const destroyFn = vi.fn(); + const mockView = { + dispatch: dispatchFn, + destroy: destroyFn, + state: { + doc: { length: 0, toString: () => '' }, + }, + }; + + const editorStateCreate = vi.fn((config: { doc: string; extensions: unknown[] }) => ({ + doc: { toString: () => config.doc, length: config.doc.length }, + extensions: config.extensions, + })); + + const editorViewConstructor = vi.fn(function (this: Record, _opts: unknown) { + Object.assign(this, mockView); + }); + + const themeFn = vi.fn((obj: unknown) => ({ theme: obj })); + const lineNumbersFn = vi.fn(() => 'lineNumbers-ext'); + const getLanguageExtensionFn = vi.fn((lang: string) => `lang-ext-${lang}`); + + return { + mockView, dispatchFn, destroyFn, editorStateCreate, editorViewConstructor, + themeFn, lineNumbersFn, getLanguageExtensionFn, + }; +}); + +Object.assign(mocks.editorViewConstructor, { + lineWrapping: 'lineWrapping-ext', + theme: mocks.themeFn, + updateListener: { of: vi.fn((fn: unknown) => ({ updateListener: fn })) }, +}); + +vi.mock('@codemirror/state', () => { + class MockCompartment { + of(ext: unknown) { return ext; } + reconfigure(ext: unknown) { return { type: 'reconfigure', ext }; } + } + return { + Compartment: MockCompartment, + EditorState: { + create: mocks.editorStateCreate, + allowMultipleSelections: { of: vi.fn((v: boolean) => ({ allowMultiple: v })) }, + tabSize: { of: vi.fn((v: number) => ({ tabSize: v })) }, + }, + }; +}); + +vi.mock('@codemirror/view', () => ({ + EditorView: mocks.editorViewConstructor, + highlightActiveLine: vi.fn(() => 'highlightActiveLine-ext'), + highlightActiveLineGutter: vi.fn(() => 'highlightActiveLineGutter-ext'), + keymap: { of: vi.fn((bindings: unknown) => ({ keymap: bindings })) }, + lineNumbers: mocks.lineNumbersFn, +})); + +vi.mock('@codemirror/commands', () => ({ + defaultKeymap: [], history: vi.fn(() => 'history-ext'), historyKeymap: [], indentWithTab: 'indentWithTab', +})); +vi.mock('@codemirror/language', () => ({ bracketMatching: vi.fn(() => 'bracketMatching-ext') })); +vi.mock('@codemirror/autocomplete', () => ({ autocompletion: vi.fn(() => 'autocompletion-ext'), completionKeymap: [] })); +vi.mock('@codemirror/theme-one-dark', () => ({ oneDark: 'oneDark-theme' })); +vi.mock('@uiw/codemirror-theme-github', () => ({ githubLight: 'githubLight-theme' })); +vi.mock('$stores/theme.svelte', () => ({ themeStore: { value: 'light' } })); +vi.mock('$lib/editor/languages', () => ({ getLanguageExtension: mocks.getLanguageExtensionFn })); + +import CodeMirrorEditor from '../CodeMirrorEditor.svelte'; + +const defaultSettings: EditorSettings = { + font_size: 14, tab_size: 4, show_line_numbers: true, word_wrap: false, theme: 'auto', +}; + +function renderEditor(overrides: Partial<{ content: string; lang: string; settings: EditorSettings }> = {}) { + return render(CodeMirrorEditor, { + props: { content: '', lang: 'python', settings: defaultSettings, ...overrides }, + }); +} + +describe('CodeMirrorEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.documentElement.classList.remove('dark'); + }); + + it('renders wrapper with h-full and w-full classes', () => { + const { container } = renderEditor(); + const wrapper = container.querySelector('.editor-wrapper'); + expect(wrapper).toBeInTheDocument(); + expect(wrapper?.classList.contains('h-full')).toBe(true); + expect(wrapper?.classList.contains('w-full')).toBe(true); + }); + + it('creates EditorState with initial content and attaches to container', async () => { + renderEditor({ content: 'print("hi")' }); + await waitFor(() => { + expect(mocks.editorStateCreate).toHaveBeenCalled(); + expect(mocks.editorStateCreate.mock.calls[0]![0].doc).toBe('print("hi")'); + expect(mocks.editorViewConstructor).toHaveBeenCalled(); + expect(mocks.editorViewConstructor.mock.calls[0]![0].parent).toBeInstanceOf(HTMLElement); + }); + }); + + it('destroys EditorView on unmount', async () => { + const { unmount } = renderEditor(); + await waitFor(() => expect(mocks.editorViewConstructor).toHaveBeenCalled()); + unmount(); + expect(mocks.destroyFn).toHaveBeenCalled(); + }); + + describe('theme selection', () => { + it.each([ + { theme: 'github', darkClass: false, expectedTheme: 'githubLight-theme' }, + { theme: 'one-dark', darkClass: false, expectedTheme: 'oneDark-theme' }, + { theme: 'auto', darkClass: true, expectedTheme: 'oneDark-theme' }, + { theme: 'auto', darkClass: false, expectedTheme: 'githubLight-theme' }, + ])('selects $expectedTheme for theme=$theme dark=$darkClass', async ({ theme, darkClass, expectedTheme }) => { + if (darkClass) document.documentElement.classList.add('dark'); + renderEditor({ settings: { ...defaultSettings, theme } }); + await waitFor(() => { + // applySettings dispatches theme reconfigure; verify the last theme-related extension + const themeArgs = mocks.dispatchFn.mock.calls.find( + (call: unknown[]) => { + const effect = (call[0] as { effects?: { ext?: unknown } })?.effects; + return effect && 'type' in effect && (effect as { type: string }).type === 'reconfigure' + && (effect as { ext?: unknown }).ext === expectedTheme; + }, + ); + // At minimum verify the view was initialized with the right theme in extensions + expect(mocks.editorStateCreate).toHaveBeenCalled(); + const extensions = mocks.editorStateCreate.mock.calls[0]![0].extensions; + expect(extensions).toContain(expectedTheme); + }); + }); + }); + + describe('settings reconfiguration', () => { + it('dispatches font size reconfigure with correct pixel value', async () => { + renderEditor({ settings: { ...defaultSettings, font_size: 18 } }); + await waitFor(() => { + expect(mocks.themeFn).toHaveBeenCalledWith( + expect.objectContaining({ '.cm-content': { fontSize: '18px' } }), + ); + }); + }); + + it('dispatches line numbers reconfigure based on setting', async () => { + renderEditor({ settings: { ...defaultSettings, show_line_numbers: true } }); + await waitFor(() => { + expect(mocks.lineNumbersFn).toHaveBeenCalled(); + }); + }); + + it('passes correct language extension for lang prop', async () => { + renderEditor({ lang: 'go' }); + await waitFor(() => { + expect(mocks.getLanguageExtensionFn).toHaveBeenCalledWith('go'); + }); + }); + }); +}); diff --git a/frontend/src/components/editor/__tests__/LanguageSelect.test.ts b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts new file mode 100644 index 00000000..3e664469 --- /dev/null +++ b/frontend/src/components/editor/__tests__/LanguageSelect.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, within, fireEvent, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { setupAnimationMock } from '../../../__tests__/test-utils'; + +vi.mock('@lucide/svelte', async () => + (await import('../../../__tests__/test-utils')).createMockIconModule('ChevronDown', 'ChevronRight')); + +import LanguageSelect from '../LanguageSelect.svelte'; + +const RUNTIMES = { + python: { versions: ['3.11', '3.10', '3.9'], image: '', display_name: 'Python' }, + node: { versions: ['20', '18'], image: '', display_name: 'Node' }, + go: { versions: ['1.21'], image: '', display_name: 'Go' }, +}; + +const defaultProps = { runtimes: RUNTIMES, lang: 'python', version: '3.11', onselect: vi.fn() }; + +function renderSelect(overrides: Partial = {}) { + const props = { ...defaultProps, onselect: vi.fn(), ...overrides }; + return { ...render(LanguageSelect, { props }), onselect: props.onselect }; +} + +async function openMenu() { + const user = userEvent.setup(); + const result = renderSelect(); + await user.click(screen.getByRole('button', { name: /python 3\.11/i })); + return { user, ...result }; +} + +describe('LanguageSelect', () => { + beforeEach(() => { + setupAnimationMock(); + }); + + describe('trigger button', () => { + it('shows current language and version with aria-haspopup', () => { + renderSelect(); + const btn = screen.getByRole('button', { name: /python 3\.11/i }); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute('aria-haspopup', 'menu'); + }); + + it('shows "Unavailable" and is disabled when no runtimes', () => { + renderSelect({ runtimes: {} }); + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + }); + + describe('dropdown menu', () => { + it('opens on click and lists all languages', async () => { + await openMenu(); + expect(screen.getByRole('menu', { name: 'Select language and version' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /python/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /node/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /go/i })).toBeInTheDocument(); + }); + + it('closes menu on second click', async () => { + const { user } = await openMenu(); + await user.click(screen.getByRole('button', { name: /python 3\.11/i })); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + }); + + describe('version submenu', () => { + it('shows all versions on language hover with correct aria-checked', async () => { + const { user } = await openMenu(); + await user.hover(screen.getByRole('menuitem', { name: /python/i })); + const versionMenu = screen.getByRole('menu', { name: /python versions/i }); + const versions = within(versionMenu).getAllByRole('menuitemradio'); + expect(versions).toHaveLength(3); + expect(within(versionMenu).getByRole('menuitemradio', { name: '3.11' })).toHaveAttribute('aria-checked', 'true'); + expect(within(versionMenu).getByRole('menuitemradio', { name: '3.10' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('calls onselect and closes menu on version click', async () => { + const { user, onselect } = await openMenu(); + await user.hover(screen.getByRole('menuitem', { name: /node/i })); + const nodeMenu = screen.getByRole('menu', { name: /node versions/i }); + await user.click(within(nodeMenu).getByRole('menuitemradio', { name: '20' })); + expect(onselect).toHaveBeenCalledWith('node', '20'); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + + it('switches version submenu when hovering different language', async () => { + const { user } = await openMenu(); + await user.hover(screen.getByRole('menuitem', { name: /python/i })); + expect(screen.getByRole('menu', { name: /python versions/i })).toBeInTheDocument(); + + await user.hover(screen.getByRole('menuitem', { name: /node/i })); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: /python versions/i })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('menu', { name: /node versions/i })).toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it.each([ + { key: '{ArrowDown}', label: 'ArrowDown' }, + { key: '{Enter}', label: 'Enter' }, + ])('opens menu with $label on trigger', async ({ key }) => { + const user = userEvent.setup(); + renderSelect(); + screen.getByRole('button', { name: /python 3\.11/i }).focus(); + await user.keyboard(key); + expect(screen.getByRole('menu', { name: 'Select language and version' })).toBeInTheDocument(); + }); + + it('closes menu with Escape on trigger', async () => { + await openMenu(); + const trigger = screen.getByRole('button', { name: /python 3\.11/i }); + await fireEvent.keyDown(trigger, { key: 'Escape' }); + await waitFor(() => { + expect(screen.queryByRole('menu', { name: 'Select language and version' })).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/components/editor/__tests__/OutputPanel.test.ts b/frontend/src/components/editor/__tests__/OutputPanel.test.ts new file mode 100644 index 00000000..39853c30 --- /dev/null +++ b/frontend/src/components/editor/__tests__/OutputPanel.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import type { ExecutionResult } from '$lib/api'; +import type { ExecutionPhase } from '$lib/editor'; + +const mocks = vi.hoisted(() => ({ + addToast: vi.fn(), +})); + +vi.mock('@lucide/svelte', async () => + (await import('../../../__tests__/test-utils')).createMockIconModule('AlertTriangle', 'FileText', 'Copy')); +vi.mock('svelte-sonner', async () => + (await import('../../../__tests__/test-utils')).createToastMock(mocks.addToast)); +vi.mock('$components/Spinner.svelte', async () => + (await import('../../../__tests__/test-utils')).createMockSvelteComponent('
Loading...
', 'spinner')); + +import OutputPanel from '../OutputPanel.svelte'; + +function makeResult(overrides: Partial = {}): ExecutionResult { + return { + execution_id: 'exec-123', + status: 'completed', + stdout: null, + stderr: null, + lang: 'python', + lang_version: '3.11', + resource_usage: null, + exit_code: 0, + error_type: null, + ...overrides, + }; +} + +type IdleProps = { phase: ExecutionPhase; result: ExecutionResult | null; error: string | null }; + +function renderIdle(overrides: Partial = {}) { + return render(OutputPanel, { + props: { phase: 'idle' as ExecutionPhase, result: null, error: null, ...overrides }, + }); +} + +describe('OutputPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows heading and prompt text when idle with no result or error', () => { + renderIdle(); + expect(screen.getByText('Execution Output')).toBeInTheDocument(); + expect(screen.getByText(/Write some code and click "Run Script"/)).toBeInTheDocument(); + }); + + describe('spinner phases', () => { + it.each([ + ['starting', 'Starting...'], + ['queued', 'Queued...'], + ['scheduled', 'Scheduled...'], + ['running', 'Running...'], + ] as const)('phase "%s" shows spinner with label "%s"', (phase, label) => { + renderIdle({ phase: phase as ExecutionPhase }); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.queryByText(/Write some code/)).not.toBeInTheDocument(); + }); + }); + + it('shows error message when error present and no result', () => { + renderIdle({ error: 'Connection timeout' }); + expect(screen.getByText('Execution Failed')).toBeInTheDocument(); + expect(screen.getByText('Connection timeout')).toBeInTheDocument(); + }); + + describe('result rendering', () => { + it.each([ + ['completed', 'bg-green-50'], + ['error', 'bg-red-50'], + ['failed', 'bg-red-50'], + ['running', 'bg-blue-50'], + ['queued', 'bg-yellow-50'], + ] as const)('status "%s" shows badge with %s class', (status, expectedClass) => { + const { container } = renderIdle({ + result: makeResult({ status: status as ExecutionResult['status'] }), + }); + expect(screen.getByText(`Status: ${status}`)).toBeInTheDocument(); + expect(container.querySelector(`span.${expectedClass}`)).not.toBeNull(); + }); + + it.each([ + { field: 'stdout', heading: 'Output:', content: 'Hello World!' }, + { field: 'stderr', heading: 'Errors:', content: 'NameError: x is not defined' }, + ])('shows $heading when $field present', ({ field, heading, content }) => { + renderIdle({ result: makeResult({ [field]: content }) }); + expect(screen.getByText(heading)).toBeInTheDocument(); + }); + + it('hides stdout and stderr sections when both null', () => { + renderIdle({ result: makeResult() }); + expect(screen.queryByText('Output:')).not.toBeInTheDocument(); + expect(screen.queryByText('Errors:')).not.toBeInTheDocument(); + }); + + it('shows execution ID copy button when execution_id present', () => { + renderIdle({ result: makeResult({ execution_id: 'abc-123' }) }); + expect(screen.getByLabelText('Click to copy execution ID')).toBeInTheDocument(); + }); + }); + + describe('resource usage computations', () => { + it.each([ + { + label: 'normal CPU', + usage: { cpu_time_jiffies: 50, clk_tck_hertz: 100, peak_memory_kb: 10240, execution_time_wall_seconds: 2.345 }, + expectedCpu: '500.000 m', + expectedMem: '10.000 MiB', + expectedTime: '2.345 s', + }, + { + label: 'zero jiffies shows "< X m"', + usage: { cpu_time_jiffies: 0, clk_tck_hertz: 100, peak_memory_kb: 2048, execution_time_wall_seconds: 0.5 }, + expectedCpu: '< 10 m', + expectedMem: '2.000 MiB', + expectedTime: '0.500 s', + }, + { + label: 'missing clk_tck defaults to 100', + usage: { cpu_time_jiffies: 200, peak_memory_kb: 512, execution_time_wall_seconds: 1 }, + expectedCpu: '2000.000 m', + expectedMem: '0.500 MiB', + expectedTime: '1.000 s', + }, + ])('$label', ({ usage, expectedCpu, expectedMem, expectedTime }) => { + renderIdle({ result: makeResult({ resource_usage: usage }) }); + expect(screen.getByText('Resource Usage:')).toBeInTheDocument(); + expect(screen.getByText(expectedCpu)).toBeInTheDocument(); + expect(screen.getByText(expectedMem)).toBeInTheDocument(); + expect(screen.getByText(expectedTime)).toBeInTheDocument(); + }); + + it('hides resource usage when not present', () => { + renderIdle({ result: makeResult() }); + expect(screen.queryByText('Resource Usage:')).not.toBeInTheDocument(); + }); + }); + + describe('copy to clipboard', () => { + let writeTextMock: ReturnType; + + function mockClipboard(resolves = true) { + writeTextMock = resolves + ? vi.fn().mockResolvedValue(undefined) + : vi.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + writable: true, + configurable: true, + }); + } + + it.each([ + { target: 'stdout', ariaLabel: 'Copy output to clipboard', text: 'hello world', toastLabel: 'Output' }, + { target: 'stderr', ariaLabel: 'Copy error text to clipboard', text: 'some error', toastLabel: 'Error text' }, + { target: 'execution_id', ariaLabel: 'Click to copy execution ID', text: 'uuid-abc', toastLabel: 'Execution ID' }, + ])('copies $target and shows success toast', async ({ target, ariaLabel, text, toastLabel }) => { + const user = userEvent.setup(); + mockClipboard(); + renderIdle({ + result: makeResult({ [target]: text }), + }); + await user.click(screen.getByLabelText(ariaLabel)); + expect(writeTextMock).toHaveBeenCalledWith(text); + expect(mocks.addToast).toHaveBeenCalledWith('success', `${toastLabel} copied to clipboard`); + }); + + it('shows error toast when clipboard write fails', async () => { + const user = userEvent.setup(); + mockClipboard(false); + renderIdle({ result: makeResult({ stdout: 'x' }) }); + await user.click(screen.getByLabelText('Copy output to clipboard')); + expect(mocks.addToast).toHaveBeenCalledWith('error', 'Failed to copy output'); + }); + }); + + describe('render priority', () => { + it('spinner takes priority over result', () => { + renderIdle({ + phase: 'running' as ExecutionPhase, + result: makeResult({ stdout: 'data' }), + }); + expect(screen.getByText('Running...')).toBeInTheDocument(); + expect(screen.queryByText('Output:')).not.toBeInTheDocument(); + }); + + it('result takes priority over error', () => { + renderIdle({ + result: makeResult({ status: 'error' as ExecutionResult['status'] }), + error: 'Something went wrong', + }); + expect(screen.getByText(/Status: error/)).toBeInTheDocument(); + expect(screen.queryByText('Execution Failed')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/editor/__tests__/ResourceLimits.test.ts b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts new file mode 100644 index 00000000..bb00ce8d --- /dev/null +++ b/frontend/src/components/editor/__tests__/ResourceLimits.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { setupAnimationMock } from '../../../__tests__/test-utils'; + +vi.mock('@lucide/svelte', async () => + (await import('../../../__tests__/test-utils')).createMockIconModule( + 'MessageSquare', 'ChevronUp', 'ChevronDown', 'Cpu', 'MemoryStick', 'Clock', + )); + +import ResourceLimits from '../ResourceLimits.svelte'; + +const LIMITS = { + cpu_limit: '500m', + memory_limit: '256Mi', + cpu_request: '100m', + memory_request: '64Mi', + execution_timeout: 30, + supported_runtimes: {}, +}; + +describe('ResourceLimits', () => { + beforeEach(() => { + setupAnimationMock(); + }); + + it('renders nothing when limits is null', () => { + const { container } = render(ResourceLimits, { props: { limits: null } }); + expect(container.textContent?.trim()).toBe(''); + }); + + it('renders collapsed toggle button with aria-expanded=false when limits provided', () => { + render(ResourceLimits, { props: { limits: LIMITS } }); + const btn = screen.getByRole('button', { name: /Resource Limits/i }); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute('aria-expanded', 'false'); + }); + + it('expands panel on click showing all limit values', async () => { + const user = userEvent.setup(); + render(ResourceLimits, { props: { limits: LIMITS } }); + await user.click(screen.getByRole('button', { name: /Resource Limits/i })); + + expect(screen.getByRole('button', { name: /Resource Limits/i })).toHaveAttribute('aria-expanded', 'true'); + }); + + it.each([ + { label: 'CPU Limit', value: '500m' }, + { label: 'Memory Limit', value: '256Mi' }, + { label: 'Timeout', value: '30s' }, + ])('shows $label = $value when expanded', async ({ label, value }) => { + const user = userEvent.setup(); + render(ResourceLimits, { props: { limits: LIMITS } }); + await user.click(screen.getByRole('button', { name: /Resource Limits/i })); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText(value)).toBeInTheDocument(); + }); + + it('collapses panel on second click', async () => { + const user = userEvent.setup(); + render(ResourceLimits, { props: { limits: LIMITS } }); + const btn = screen.getByRole('button', { name: /Resource Limits/i }); + + await user.click(btn); + expect(btn).toHaveAttribute('aria-expanded', 'true'); + + await user.click(btn); + expect(btn).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/frontend/src/components/editor/__tests__/SavedScripts.test.ts b/frontend/src/components/editor/__tests__/SavedScripts.test.ts new file mode 100644 index 00000000..8ae32a4a --- /dev/null +++ b/frontend/src/components/editor/__tests__/SavedScripts.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { setupAnimationMock } from '../../../__tests__/test-utils'; + +vi.mock('@lucide/svelte', async () => + (await import('../../../__tests__/test-utils')).createMockIconModule('List', 'Trash2')); + +import SavedScripts from '../SavedScripts.svelte'; + +interface SavedScript { + id: string; + name: string; + script: string; + lang?: string; + lang_version?: string; +} + +function createScripts(count: number): SavedScript[] { + return Array.from({ length: count }, (_, i) => ({ + id: `script-${i + 1}`, + name: `Script ${i + 1}`, + script: `print("hello ${i + 1}")`, + lang: 'python', + lang_version: '3.11', + })); +} + +function renderScripts(scripts: SavedScript[] = []) { + const onload = vi.fn(); + const ondelete = vi.fn(); + const onrefresh = vi.fn(); + const result = render(SavedScripts, { props: { scripts, onload, ondelete, onrefresh } }); + return { ...result, onload, ondelete, onrefresh }; +} + +async function renderAndExpand(scripts: SavedScript[] = []) { + const user = userEvent.setup(); + const result = renderScripts(scripts); + await user.click(screen.getByRole('button', { name: /Show Saved Scripts/i })); + return { user, ...result }; +} + +describe('SavedScripts', () => { + beforeEach(() => { + setupAnimationMock(); + }); + + it('renders collapsed with heading, toggle button, and scripts hidden', () => { + renderScripts(createScripts(2)); + expect(screen.getByText('Saved Scripts')).toBeInTheDocument(); + const btn = screen.getByRole('button', { name: /Show Saved Scripts/i }); + expect(btn).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Script 1')).not.toBeInTheDocument(); + }); + + it('calls onrefresh only when expanding, not when collapsing', async () => { + const { onrefresh } = await renderAndExpand(); + expect(onrefresh).toHaveBeenCalledOnce(); + onrefresh.mockClear(); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Hide Saved Scripts/i })); + expect(onrefresh).not.toHaveBeenCalled(); + }); + + it('shows empty state when no scripts', async () => { + await renderAndExpand(); + expect(screen.getByText('No saved scripts yet.')).toBeInTheDocument(); + }); + + it('shows all script names when expanded', async () => { + const scripts = createScripts(3); + await renderAndExpand(scripts); + for (const s of scripts) { + expect(screen.getByText(s.name)).toBeInTheDocument(); + } + }); + + it.each([ + { lang: 'go', lang_version: '1.21', expected: 'go 1.21' }, + { lang: undefined, lang_version: undefined, expected: 'python 3.11' }, + ])('displays "$expected" for lang=$lang version=$lang_version', async ({ lang, lang_version, expected }) => { + await renderAndExpand([{ id: '1', name: 'Test', script: 'x', lang, lang_version }]); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('calls onload with full script object when clicking a script name', async () => { + const scripts = createScripts(2); + const { onload } = await renderAndExpand(scripts); + const user = userEvent.setup(); + await user.click(screen.getByText('Script 2')); + expect(onload).toHaveBeenCalledWith(scripts[1]); + }); + + it('calls ondelete with script id and does not trigger onload (stopPropagation)', async () => { + const scripts = createScripts(1); + const { onload, ondelete } = await renderAndExpand(scripts); + const user = userEvent.setup(); + await user.click(screen.getByTitle('Delete Script 1')); + expect(ondelete).toHaveBeenCalledWith('script-1'); + expect(onload).not.toHaveBeenCalled(); + }); + + it('renders load button title with lang info', async () => { + await renderAndExpand([{ id: '1', name: 'Go App', script: 'x', lang: 'go', lang_version: '1.21' }]); + expect(screen.getByTitle('Load Go App (go 1.21)')).toBeInTheDocument(); + }); +});