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
167 changes: 167 additions & 0 deletions frontend/src/components/editor/__tests__/CodeMirrorEditor.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, _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');
});
});
});
});
125 changes: 125 additions & 0 deletions frontend/src/components/editor/__tests__/LanguageSelect.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defaultProps> = {}) {
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();
});
});
});
});
Loading
Loading