/** * Tests for MarkdownEditor component. */ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { MarkdownEditor } from './markdown-editor' describe('MarkdownEditor', () => { it('renders a labeled textarea', () => { render() expect(screen.getByRole('textbox', { name: 'Content' })).toBeInTheDocument() }) it('calls onChange when typing', async () => { const user = userEvent.setup() const onChange = vi.fn() render() await user.type(screen.getByRole('textbox', { name: 'Content' }), 'Hello') expect(onChange).toHaveBeenCalled() }) it('renders toolbar with formatting buttons', () => { render() const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }) expect(toolbar).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Italic' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Quote' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'List' })).toBeInTheDocument() }) it('wraps selected text with bold markers', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement // Select "world" textarea.setSelectionRange(6, 11) await user.click(screen.getByRole('button', { name: 'Bold' })) expect(onChange).toHaveBeenCalledWith('hello **world**') }) it('wraps selected text with italic markers', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 11) await user.click(screen.getByRole('button', { name: 'Italic' })) expect(onChange).toHaveBeenCalledWith('hello *world*') }) it('inserts link template when no selection', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 6) await user.click(screen.getByRole('button', { name: 'Link' })) expect(onChange).toHaveBeenCalledWith('hello [text](url)') }) it('wraps selected text with code markers', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 10) await user.click(screen.getByRole('button', { name: 'Code' })) expect(onChange).toHaveBeenCalledWith('hello `code`') }) it('prefixes line with quote marker', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(0, 5) await user.click(screen.getByRole('button', { name: 'Quote' })) expect(onChange).toHaveBeenCalledWith('> hello') }) it('prefixes line with list marker', async () => { const user = userEvent.setup() const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(0, 5) await user.click(screen.getByRole('button', { name: 'List' })) expect(onChange).toHaveBeenCalledWith('- hello') }) it('supports roving tabindex on toolbar buttons', async () => { const user = userEvent.setup() render() const boldBtn = screen.getByRole('button', { name: 'Bold' }) const italicBtn = screen.getByRole('button', { name: 'Italic' }) // First button is tabbable, others have tabindex -1 expect(boldBtn).toHaveAttribute('tabindex', '0') expect(italicBtn).toHaveAttribute('tabindex', '-1') // Focus first button, arrow right to move focus boldBtn.focus() await user.keyboard('{ArrowRight}') expect(italicBtn).toHaveFocus() }) it('shows error message when provided', () => { render( ) expect(screen.getByText('Content is required')).toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'Content' })).toHaveAttribute('aria-invalid', 'true') }) describe('smart link paste', () => { function pasteUrl(textarea: HTMLTextAreaElement, url: string) { const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: { getData: (type: string) => (type === 'text/plain' ? url : '') }, }) textarea.dispatchEvent(event) return event } it('wraps selected text as markdown link when pasting a URL', () => { const onChange = vi.fn() render( ) const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement // Select "this" textarea.setSelectionRange(6, 10) pasteUrl(textarea, 'https://example.com') expect(onChange).toHaveBeenCalledWith('check [this](https://example.com) out') }) it('does not intercept paste when no text is selected', () => { const onChange = vi.fn() render() const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 6) const event = pasteUrl(textarea, 'https://example.com') // Event should not be prevented — default paste behavior expect(event.defaultPrevented).toBe(false) expect(onChange).not.toHaveBeenCalled() }) it('does not intercept paste when pasted text is not a URL', () => { const onChange = vi.fn() render( ) const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 11) const event = pasteUrl(textarea, 'not a url') expect(event.defaultPrevented).toBe(false) expect(onChange).not.toHaveBeenCalled() }) it('handles http:// URLs', () => { const onChange = vi.fn() render( ) const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(6, 10) pasteUrl(textarea, 'http://example.com') expect(onChange).toHaveBeenCalledWith('click [here](http://example.com) please') }) it('places cursor after the inserted link', () => { const onChange = vi.fn() render( ) const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement textarea.setSelectionRange(4, 8) pasteUrl(textarea, 'https://docs.example.com') expect(onChange).toHaveBeenCalledWith('see [docs](https://docs.example.com) now') }) }) it('passes axe accessibility check', async () => { const { container } = render( ) const results = await axe(container) expect(results).toHaveNoViolations() }) })