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(); + }); +});