From 2bb290ebe8ac315b907ddf509418ef3d38e88ae4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 10 May 2026 08:21:52 +0200 Subject: [PATCH] test(tag): expand TagParentPicker keyboard + excludeIds coverage ArrowUp wrap-around, Escape close, Enter without selection no-op, keydown without dropdown no-throw, Enter with active selection selects, excludeIds filter works, parentId fallback as subtitle. 7 new tests covering ~12 branches. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/tag/TagParentPicker.svelte.spec.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/frontend/src/lib/tag/TagParentPicker.svelte.spec.ts b/frontend/src/lib/tag/TagParentPicker.svelte.spec.ts index ac78dac2..4db7d59c 100644 --- a/frontend/src/lib/tag/TagParentPicker.svelte.spec.ts +++ b/frontend/src/lib/tag/TagParentPicker.svelte.spec.ts @@ -199,4 +199,125 @@ describe('TagParentPicker – parent name subtitle', () => { // Only the tag name should appear (no subtitle) await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument(); }); + + it('shows the parentId as subtitle when allTags omits the parent', async () => { + mockFetchWithTags([{ id: 't2', name: 'Keller', parentId: 'unknown-parent-id' }]); + render(TagParentPicker, { name: 'parentId', allTags: [] }); + + const input = page.getByRole('combobox'); + await input.fill('K'); + await vi.advanceTimersByTimeAsync(300); + + // When parent not found in allTags, fallback shows the parentId itself + await expect.element(page.getByText('unknown-parent-id')).toBeInTheDocument(); + }); +}); + +describe('TagParentPicker – keyboard navigation', () => { + it('ArrowUp wraps around to last option', async () => { + mockFetchWithTags([ + { id: 't1', name: 'Haus' }, + { id: 't2', name: 'Garten' } + ]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('a'); + await vi.advanceTimersByTimeAsync(300); + + const el = await input.element(); + // Without prior arrow-down, ArrowUp from -1 wraps via modular arithmetic + el.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }) + ); + await vi.advanceTimersByTimeAsync(0); + + expect(el.getAttribute('aria-activedescendant')).toBeTruthy(); + }); + + it('Escape closes the dropdown', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + const el = await input.element(); + el.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ); + await vi.advanceTimersByTimeAsync(0); + + // Listbox should be gone + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('Enter without active selection does nothing', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + const el = await input.element(); + el.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + await vi.advanceTimersByTimeAsync(0); + + // Hidden input should still be empty + expect(hiddenInput('parentId')?.value).toBe(''); + }); + + it('keydown with no active dropdown is a no-op', async () => { + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + const el = await input.element(); + expect(() => + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + ).not.toThrow(); + }); + + it('Enter with active selection selects the highlighted tag', async () => { + mockFetchWithTags([ + { id: 't1', name: 'Haus' }, + { id: 't2', name: 'Garten' } + ]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('a'); + await vi.advanceTimersByTimeAsync(300); + + const el = await input.element(); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await vi.advanceTimersByTimeAsync(0); + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await vi.advanceTimersByTimeAsync(0); + + // Some tag selected + expect(hiddenInput('parentId')?.value).toBeTruthy(); + }); +}); + +describe('TagParentPicker – excludeIds filter', () => { + it('filters out tags whose id is in excludeIds', async () => { + mockFetchWithTags([ + { id: 't1', name: 'Haus' }, + { id: 't2', name: 'Keller' } + ]); + render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] }); + + const input = page.getByRole('combobox'); + await input.fill('a'); + await vi.advanceTimersByTimeAsync(300); + + // Only Keller should be visible + const options = document.querySelectorAll('[role="option"]'); + expect(options.length).toBe(1); + expect(options[0].textContent).toContain('Keller'); + }); });