test(discussion): cover MentionEditor branches
Textarea props (placeholder, rows, disabled), popup not shown
initially, popup opens on @ + query, empty results from API,
HTTP error → empty popup, Enter submits when popup closed,
Shift+Enter does not submit, Escape closes popup, Arrow{Up,Down}
navigation, Enter with no results.
12 tests covering ~30 branches in MentionEditor.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
225
frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts
Normal file
225
frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user