From b26ae52fbb5115d252fe0698eec6ddcb2478c609 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 11 May 2026 17:26:49 +0200 Subject: [PATCH] test(discussion): rewrite MentionEditor test with vi.waitFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 16 setTimeout(350ms / 30ms / 50ms) sleeps with vi.waitFor on the actual signal — popup listbox appearance/disappearance, option aria-selected state — so the test no longer races the 200ms internal debounce against the real clock under CI load. Co-Authored-By: Claude Opus 4.7 --- .../discussion/MentionEditor.svelte.test.ts | 148 +++++++----------- 1 file changed, 58 insertions(+), 90 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts index 8d9081a9..ee019f86 100644 --- a/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionEditor.svelte.test.ts @@ -28,6 +28,14 @@ describe('MentionEditor', () => { 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: { @@ -75,17 +83,12 @@ describe('MentionEditor', () => { }); 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 })); + fireAtMention(ta, 'Hi @An'); - // debounce 200ms + result render - await new Promise((r) => setTimeout(r, 350)); - - const popup = document.querySelector('[role="listbox"]'); - expect(popup).not.toBeNull(); + // 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 () => { @@ -99,13 +102,8 @@ describe('MentionEditor', () => { }); 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 })); + fireAtMention(ta, '@Zzzz'); - await new Promise((r) => setTimeout(r, 350)); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); }); @@ -117,14 +115,8 @@ describe('MentionEditor', () => { }); 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 })); + fireAtMention(ta, '@Anna'); - 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(); }); @@ -160,18 +152,15 @@ describe('MentionEditor', () => { }); 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)); + fireAtMention(ta, '@An'); + await vi.waitFor(() => { + expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + }); 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(); + await vi.waitFor(() => { + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); }); it('navigates results with ArrowDown and ArrowUp', async () => { @@ -180,26 +169,25 @@ describe('MentionEditor', () => { }); 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)); + fireAtMention(ta, '@An'); + await vi.waitFor(() => { + expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(1); + }); 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'); + 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 new Promise((r) => setTimeout(r, 30)); - const opts2 = document.querySelectorAll('[role="option"]'); - expect(opts2[0]?.getAttribute('aria-selected')).toBe('true'); + await vi.waitFor(() => { + const opts = document.querySelectorAll('[role="option"]'); + expect(opts[0]?.getAttribute('aria-selected')).toBe('true'); + }); }); - it('closes the popup when Enter is hit and no results are present', async () => { + 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' } }) @@ -209,67 +197,53 @@ describe('MentionEditor', () => { }); 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)); + fireAtMention(ta, '@Zz'); + await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); 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(); + // 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 fills the textarea', async () => { + it('selects a user via mousedown click and closes the popup', 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)); + 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 })); + firstOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - await new Promise((r) => setTimeout(r, 50)); - // After select, popup is closed - const popup = document.querySelector('[role="listbox"]'); - expect(popup).toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); }); - it('selects via Enter when results are present', async () => { + 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; - 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)); + fireAtMention(ta, '@An'); + await vi.waitFor(() => { + expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0); + }); - // Move highlight to first result with ArrowDown then Enter ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await new Promise((r) => setTimeout(r, 30)); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await new Promise((r) => setTimeout(r, 50)); - // Popup closed after select - const popup = document.querySelector('[role="listbox"]'); - expect(popup).toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); }); - it('handles fetch network throw gracefully', async () => { + it('handles fetch network throw gracefully (empty popup label)', async () => { fetchSpy.mockImplementationOnce(async () => { throw new Error('network down'); }); @@ -279,14 +253,8 @@ describe('MentionEditor', () => { }); 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 })); + fireAtMention(ta, '@An'); - await new Promise((r) => setTimeout(r, 350)); - // Empty popup label after thrown fetch await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); }); });