From 58a30a6e2e1a329b61305a8c64159a11612eceb6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 2 Jun 2026 19:45:18 +0200 Subject: [PATCH] test(transcription): AC-6 single-dropdown invariant + stale-fetch guard (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump on open discards a superseded open's in-flight fetch (open A → open B → A resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the fresh-@ path still inserts the typed text as displayName after the controller refactor. Co-Authored-By: Claude Opus 4.8 --- .../PersonMentionEditor.svelte.spec.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index a3596e3b..d474b768 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -1047,3 +1047,133 @@ describe('PersonMentionEditor — #628 re-edit pencil', () => { await expect.element(dismissButton()).toBeVisible(); }); }); + +// ─── #628 AC-6: at most one mention dropdown open at a time ─────────────────── + +describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => { + const TWO_MENTIONS = [ + { personId: 'p-aug', displayName: 'Aug' }, + { personId: 'p-bert', displayName: 'Bert' } + ]; + + function mentionButtons(): HTMLButtonElement[] { + return Array.from( + document.querySelectorAll('[data-type="mention"] button') + ) as HTMLButtonElement[]; + } + + function click(el: HTMLElement) { + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + + function listboxCount() { + return document.querySelectorAll('[role="listbox"]').length; + } + + it('pencil → pencil: opening a second mention pencil closes the first', async () => { + mockFetchEmpty(); + renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS }); + await vi.waitFor(() => expect(mentionButtons().length).toBe(2)); + const [pencilA, pencilB] = mentionButtons(); + + click(pencilA); + await vi.waitFor(() => expect(listboxCount()).toBe(1)); + + click(pencilB); + await vi.waitFor(async () => { + expect(listboxCount()).toBe(1); + await expect.element(page.getByRole('searchbox')).toHaveValue('Bert'); + }); + }); + + it('fresh-@ → pencil: activating a pencil closes the open fresh-@ dropdown', async () => { + mockFetchEmpty(); + renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); + await vi.waitFor(() => expect(mentionButtons().length).toBe(1)); + + // Open a fresh-@ dropdown (prefilled with the typed query "x"). + await userEvent.type(page.getByRole('textbox'), '@x'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('searchbox')).toHaveValue('x'); + }); + + // Now activate the saved mention's pencil — the fresh dropdown must give way. + click(mentionButtons()[0]); + await vi.waitFor(async () => { + expect(listboxCount()).toBe(1); + await expect.element(page.getByRole('searchbox')).toHaveValue('Aug'); + }); + }); + + it('pencil → fresh-@: typing @ in the editor closes the open re-edit dropdown', async () => { + mockFetchEmpty(); + renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); + await vi.waitFor(() => expect(mentionButtons().length).toBe(1)); + + click(mentionButtons()[0]); + await vi.waitFor(async () => { + await expect.element(page.getByRole('searchbox')).toHaveValue('Aug'); + }); + + // Typing @ in the editor starts a fresh suggestion — the re-edit dropdown + // must give way to it (single owner, both orderings). + await userEvent.type(page.getByRole('textbox'), '@y'); + await vi.waitFor(async () => { + expect(listboxCount()).toBe(1); + await expect.element(page.getByRole('searchbox')).toHaveValue('y'); + }); + }); + + it('regression: fresh-@ still inserts the typed text as displayName (#380 AC-1 intact)', async () => { + mockFetchWithPersons(); + const host = renderHost(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + const option = (await page + .getByRole('option', { name: /Auguste Raddatz/ }) + .element()) as HTMLElement; + option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); + }); + }); + + it('discards a stale fetch from a superseded open (open A → open B → A resolves)', async () => { + // A's fetch hangs; B's resolves empty. When A finally resolves with a + // person, the request-token guard must discard it so B's dropdown is not + // repopulated. Deterministic — no sleeps. + let resolveA!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void; + const pendingA = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>((r) => { + resolveA = r; + }); + let call = 0; + const fetchMock = vi.fn().mockImplementation(() => { + call += 1; + if (call === 1) return pendingA; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) }); + }); + vi.stubGlobal('fetch', fetchMock); + + renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS }); + await vi.waitFor(() => expect(mentionButtons().length).toBe(2)); + const [pencilA, pencilB] = mentionButtons(); + + click(pencilA); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Aug')); + }); + + click(pencilB); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Bert')); + }); + + // A's stale response arrives last — it must NOT repopulate B's dropdown. + resolveA({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) }); + await tick(); + await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); + }); +});