diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index d474b768..479291b8 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -1177,3 +1177,135 @@ describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => { await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); + +// ─── #628 AC-7: pencil is inert when the editor is disabled (WCAG 2.1.1) ────── + +describe('PersonMentionEditor — #628 AC-7 disabled editor', () => { + function mentionButton(): HTMLButtonElement | null { + return document.querySelector('[data-type="mention"] button'); + } + + it('renders the pencil disabled, aria-disabled and out of tab order when disabled', async () => { + renderHost({ + value: '@Aug ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }], + disabled: true + }); + await vi.waitFor(() => { + const btn = mentionButton(); + expect(btn).not.toBeNull(); + expect(btn!.disabled).toBe(true); + expect(btn!.getAttribute('aria-disabled')).toBe('true'); + expect(btn!.tabIndex).toBe(-1); + }); + }); + + it('activating the disabled pencil (keyboard or pointer) mounts no dropdown', async () => { + mockFetchWithPersons(); + renderHost({ + value: '@Aug ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }], + disabled: true + }); + await vi.waitFor(() => expect(mentionButton()).not.toBeNull()); + const btn = mentionButton()!; + + btn.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + // Give any (incorrectly) scheduled open a chance to mount before asserting. + await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); +}); + +// ─── #628 AC-8: no pencil / dropdown where there is no mention under the caret ─ + +describe('PersonMentionEditor — #628 AC-8 no mention under caret', () => { + it('plain text shows no edit pencil and no dropdown', async () => { + renderHost({ value: 'Nur Text ohne Erwaehnung', mentionedPersons: [] }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + expect(document.querySelector('[data-type="mention"]')).toBeNull(); + expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(0); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('two adjacent mentions each keep their own pencil and auto-open nothing', async () => { + renderHost({ + value: '@Aug@Bert ', + mentionedPersons: [ + { personId: 'p-aug', displayName: 'Aug' }, + { personId: 'p-bert', displayName: 'Bert' } + ] + }); + await vi.waitFor(() => + expect(document.querySelectorAll('[data-type="mention"]').length).toBe(2) + ); + // One pencil per token (no spurious pencil for the gap between them) ... + expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(2); + // ... and nothing opens just from caret position. + expect(document.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('a mention at the document start still renders its pencil', async () => { + renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); + await vi.waitFor(() => + expect(document.querySelector('[data-type="mention"] button')).not.toBeNull() + ); + }); +}); + +// ─── #628 security: query clip + personId provenance ────────────────────────── + +describe('PersonMentionEditor — #628 security', () => { + const OVERSIZED = 'A'.repeat(150); + + function mentionButton(): HTMLButtonElement { + return document.querySelector('[data-type="mention"] button') as HTMLButtonElement; + } + + it('clips an oversized stored displayName to MAX_QUERY_LENGTH in the search input, node text untouched', async () => { + mockFetchWithPersons(); + const host = renderHost({ + value: `@${OVERSIZED} `, + mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }] + }); + await vi.waitFor(() => expect(mentionButton()).not.toBeNull()); + mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('searchbox')).toBeVisible(); + }); + const search = (await page.getByRole('searchbox').element()) as HTMLInputElement; + expect(search.value.length).toBe(100); + // The preserved node text must NOT be truncated — only the search query is. + expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150); + }); + + it('re-link derives personId solely from the selected Person, never the DOM/search text', async () => { + mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna) + const host = renderHost({ + value: `@${OVERSIZED} `, + mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }] + }); + await vi.waitFor(() => expect(mentionButton()).not.toBeNull()); + mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible(); + }); + const anna = (await page + .getByRole('option', { name: /Anna Schmidt/ }) + .element()) as HTMLElement; + anna.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + // id comes from the picked Person (p-anna), not the reflected p-aug + // nor the clipped search text; the long displayName stays untouched. + expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna'); + expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150); + }); + }); +});