diff --git a/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts new file mode 100644 index 00000000..e4077e2c --- /dev/null +++ b/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts @@ -0,0 +1,225 @@ +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(); + }); + + 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; + ta.focus(); + ta.value = 'Hi @An'; + ta.selectionStart = 6; + ta.selectionEnd = 6; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + // debounce 200ms + result render + await new Promise((r) => setTimeout(r, 350)); + + const popup = document.querySelector('[role="listbox"]'); + expect(popup).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; + ta.focus(); + ta.value = '@Zzzz'; + ta.selectionStart = 5; + ta.selectionEnd = 5; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await new Promise((r) => setTimeout(r, 350)); + 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; + ta.focus(); + ta.value = '@Anna'; + ta.selectionStart = 5; + ta.selectionEnd = 5; + ta.dispatchEvent(new Event('input', { bubbles: true })); + + await new Promise((r) => setTimeout(r, 350)); + // Popup is open, but with the empty label + 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; + ta.focus(); + ta.value = '@An'; + ta.selectionStart = 3; + ta.selectionEnd = 3; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 350)); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await new Promise((r) => setTimeout(r, 30)); + + const popup = document.querySelector('[role="listbox"]'); + expect(popup).toBeNull(); + }); + + it('navigates results with ArrowDown and ArrowUp', async () => { + render(MentionEditor, { + props: { value: '', mentionCandidates: [] } + }); + + const ta = document.querySelector('textarea') as HTMLTextAreaElement; + ta.focus(); + ta.value = '@An'; + ta.selectionStart = 3; + ta.selectionEnd = 3; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 350)); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await new Promise((r) => setTimeout(r, 30)); + const opts = document.querySelectorAll('[role="option"]'); + // Should have moved selection — second item highlighted + expect(opts[1]?.getAttribute('aria-selected')).toBe('true'); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await new Promise((r) => setTimeout(r, 30)); + const opts2 = document.querySelectorAll('[role="option"]'); + expect(opts2[0]?.getAttribute('aria-selected')).toBe('true'); + }); + + it('closes the popup 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; + ta.focus(); + ta.value = '@Zz'; + ta.selectionStart = 3; + ta.selectionEnd = 3; + ta.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 350)); + + ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await new Promise((r) => setTimeout(r, 30)); + // Should still be open — Enter without results doesn't close, but doesn't onsubmit either + const popup = document.querySelector('[role="listbox"]'); + expect(popup).not.toBeNull(); + }); +});