import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import MentionEditor from './MentionEditor.svelte'; afterEach(cleanup); describe('MentionEditor', () => { let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/users/search')) { return new Response( JSON.stringify([ { id: 'u1', firstName: 'Anna', lastName: 'Schmidt' }, { id: 'u2', firstName: 'Bertha', lastName: 'Müller' } ]), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } return new Response('not found', { status: 404 }); }); }); afterEach(() => { fetchSpy?.mockRestore(); }); function fireAtMention(ta: HTMLTextAreaElement, text: string) { ta.focus(); ta.value = text; ta.selectionStart = text.length; ta.selectionEnd = text.length; ta.dispatchEvent(new Event('input', { bubbles: true })); } it('renders the textarea with the placeholder', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [], placeholder: 'Schreibe etwas…' } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; expect(ta).not.toBeNull(); expect(ta.placeholder).toBe('Schreibe etwas…'); }); it('honours the rows prop', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [], rows: 7 } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; expect(ta.rows).toBe(7); }); it('disables the textarea when disabled is true', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [], disabled: true } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; expect(ta.disabled).toBe(true); }); it('does not show the popup initially', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const popup = document.querySelector('[role="listbox"]'); expect(popup).toBeNull(); }); it('opens the popup when typing @ followed by a query', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, 'Hi @An'); // Debounce fires (200ms), fetch resolves, popup opens — vi.waitFor polls until ready. await vi.waitFor(() => { expect(document.querySelector('[role="listbox"]')).not.toBeNull(); }); }); it('renders the empty-popup label when fetch returns no results', async () => { fetchSpy.mockImplementationOnce( async () => new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }) ); render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@Zzzz'); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); }); it('clears results when fetch is not OK', async () => { fetchSpy.mockImplementationOnce(async () => new Response('error', { status: 500 })); render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@Anna'); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); }); it('calls onsubmit when Enter is pressed without a popup open', async () => { const onsubmit = vi.fn(); render(MentionEditor, { props: { value: 'Hello', mentionCandidates: [], onsubmit } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onsubmit).toHaveBeenCalledOnce(); }); it('does not call onsubmit when Shift+Enter is pressed', async () => { const onsubmit = vi.fn(); render(MentionEditor, { props: { value: 'Hello', mentionCandidates: [], onsubmit } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); expect(onsubmit).not.toHaveBeenCalled(); }); it('closes the popup when Escape is pressed', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@An'); await vi.waitFor(() => { expect(document.querySelector('[role="listbox"]')).not.toBeNull(); }); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await vi.waitFor(() => { expect(document.querySelector('[role="listbox"]')).toBeNull(); }); }); it('navigates results with ArrowDown and ArrowUp', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@An'); await vi.waitFor(() => { expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(1); }); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await vi.waitFor(() => { const opts = document.querySelectorAll('[role="option"]'); expect(opts[1]?.getAttribute('aria-selected')).toBe('true'); }); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await vi.waitFor(() => { const opts = document.querySelectorAll('[role="option"]'); expect(opts[0]?.getAttribute('aria-selected')).toBe('true'); }); }); it('keeps the popup open when Enter is hit and no results are present', async () => { fetchSpy.mockImplementationOnce( async () => new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }) ); render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@Zz'); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); // Enter with no results does not close the popup and does not submit. expect(document.querySelector('[role="listbox"]')).not.toBeNull(); }); it('selects a user via mousedown click and closes the popup', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@An'); await vi.waitFor(() => { expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0); }); const firstOption = document.querySelector('[role="option"]') as HTMLElement; firstOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); await vi.waitFor(() => { expect(document.querySelector('[role="listbox"]')).toBeNull(); }); }); it('selects via Enter when results are present and closes the popup', async () => { render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@An'); await vi.waitFor(() => { expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0); }); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await vi.waitFor(() => { expect(document.querySelector('[role="listbox"]')).toBeNull(); }); }); it('handles fetch network throw gracefully (empty popup label)', async () => { fetchSpy.mockImplementationOnce(async () => { throw new Error('network down'); }); render(MentionEditor, { props: { value: '', mentionCandidates: [] } }); const ta = document.querySelector('textarea') as HTMLTextAreaElement; fireAtMention(ta, '@An'); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); }); });