From 9ea1d6f5e190b645cd33944de816c3a134b73a31 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 11 May 2026 17:19:04 +0200 Subject: [PATCH] test(person): rewrite PersonTypeahead test with behavioral assertions Replaces 3 setTimeout sleeps with vi.waitFor on listbox / aria-expanded state and converts 2 .not.toThrow smoke tests + 1 vacuous expect(true) into assertions about the input remaining usable after fetch errors and Escape on a closed dropdown being a no-op. Co-Authored-By: Claude Opus 4.7 --- .../lib/person/PersonTypeahead.svelte.test.ts | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/person/PersonTypeahead.svelte.test.ts b/frontend/src/lib/person/PersonTypeahead.svelte.test.ts index 7e582589..b23be043 100644 --- a/frontend/src/lib/person/PersonTypeahead.svelte.test.ts +++ b/frontend/src/lib/person/PersonTypeahead.svelte.test.ts @@ -106,9 +106,9 @@ describe('PersonTypeahead', () => { const input = document.querySelector('input#s-search') as HTMLInputElement; input.dispatchEvent(new Event('focus', { bubbles: true })); - await new Promise((r) => setTimeout(r, 100)); - const listbox = document.querySelector('[role="listbox"]'); - expect(listbox).not.toBeNull(); + await vi.waitFor(() => { + expect(document.querySelector('[role="listbox"]')).not.toBeNull(); + }); }); it('updates aria-expanded when the dropdown opens', async () => { @@ -120,30 +120,35 @@ describe('PersonTypeahead', () => { expect(input.getAttribute('aria-expanded')).toBe('false'); input.dispatchEvent(new Event('focus', { bubbles: true })); - await new Promise((r) => setTimeout(r, 100)); - expect(input.getAttribute('aria-expanded')).toBe('true'); + await vi.waitFor(() => { + expect(input.getAttribute('aria-expanded')).toBe('true'); + }); }); - it('opens the dropdown via Escape key without throwing', async () => { + it('Escape key on a closed dropdown is a no-op (no listbox appears)', async () => { render(PersonTypeahead, { props: { name: 's', label: 'Absender' } }); const input = document.querySelector('input#s-search') as HTMLInputElement; - expect(() => - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) - ).not.toThrow(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(document.querySelector('[role="listbox"]')).toBeNull(); }); - it('handles fetch failure gracefully on focus', async () => { + it('keeps the input usable when fetch rejects on focus (no error UI, no crash)', async () => { fetchSpy.mockRejectedValueOnce(new Error('boom')); render(PersonTypeahead, { props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' } }); const input = document.querySelector('input#s-search') as HTMLInputElement; - expect(() => input.dispatchEvent(new Event('focus', { bubbles: true }))).not.toThrow(); + input.dispatchEvent(new Event('focus', { bubbles: true })); + + // Graceful failure: no listbox surfaces but the input stays mounted and interactive. + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + expect(document.querySelector('input#s-search')).not.toBeNull(); }); - it('handles fetch returning non-ok response on focus', async () => { + it('keeps the input usable when fetch returns a non-OK response on focus', async () => { fetchSpy.mockResolvedValueOnce(new Response('error', { status: 500 })); render(PersonTypeahead, { props: { name: 's', label: 'Absender', restrictToCorrespondentsOf: 'parent-id' } @@ -151,10 +156,9 @@ describe('PersonTypeahead', () => { const input = document.querySelector('input#s-search') as HTMLInputElement; input.dispatchEvent(new Event('focus', { bubbles: true })); - await new Promise((r) => setTimeout(r, 50)); - // Should still open the listbox (with no results) - // no throw is the assertion here - expect(true).toBe(true); + + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + expect(document.querySelector('input#s-search')).not.toBeNull(); }); it('renders the FieldLabelBadge when badge is provided', async () => { @@ -162,9 +166,7 @@ describe('PersonTypeahead', () => { props: { name: 's', label: 'Absender', badge: 'replace' } }); - // FieldLabelBadge renders a small badge element const label = document.querySelector('label[for="s-search"]'); - // The badge variant is passed; check the label has the additional badge child expect(label?.children.length).toBeGreaterThan(0); }); @@ -174,7 +176,6 @@ describe('PersonTypeahead', () => { }); const label = document.querySelector('label[for="s-search"]'); - // No badge child expect(label?.querySelector('[class*="badge"]')).toBeNull(); });