Addresses the clean-agent review of PR #717: - C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box overhangs adjacent text, so a click in the gap between two mentions could land on the invisible button and spuriously open the dropdown (AC-8 hole). Add pointer-events-none while hidden, re-enabled with the opacity reveal on hover/focus. - C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so the NodeView's 'transaction' listener missed a mid-session disable flip (stale aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead — which also skips selection-only changes, so it fires far less often. - N2: track the node across update() so the pencil opens with the live displayName (hardening; relink only swaps personId today). Tests: structural guard that the hidden pencil is pointer-events-none + reveals, and a mid-session disable-flip test (fixture gains an onReady setDisabled hook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1387 lines
52 KiB
TypeScript
1387 lines
52 KiB
TypeScript
/**
|
||
* PersonMentionEditor — Tiptap-based component tests.
|
||
*
|
||
* All old tests used document.querySelector('textarea') which is dead after
|
||
* the Tiptap migration. These tests drive the contenteditable via
|
||
* userEvent.type() and inspect the serialized output from the test host.
|
||
*/
|
||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||
import { cleanup, render } from 'vitest-browser-svelte';
|
||
import { page, userEvent } from 'vitest/browser';
|
||
import { tick } from 'svelte';
|
||
import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte';
|
||
import type { components } from '$lib/generated/api';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
// Single source of truth for the debounce window — imported from the shared
|
||
// module so the test cannot drift from production. Sara on PR #629 round 3.
|
||
import { SEARCH_DEBOUNCE_MS } from './mentionConstants';
|
||
|
||
type Person = components['schemas']['Person'];
|
||
type PersonMention = components['schemas']['PersonMention'];
|
||
|
||
/**
|
||
* Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait
|
||
* assertions in this file. 350 ms is calibrated against CI-runner jitter
|
||
* we observed pre-#629; dropping it below ~200 ms reintroduces flake.
|
||
* See PR #629 round-2 review comment #10935 (Sara).
|
||
*/
|
||
const POST_DEBOUNCE_SLACK_MS = 350;
|
||
|
||
const AUGUSTE: Person = {
|
||
id: 'p-aug',
|
||
firstName: 'Auguste',
|
||
lastName: 'Raddatz',
|
||
displayName: 'Auguste Raddatz',
|
||
personType: 'PERSON',
|
||
familyMember: false,
|
||
provisional: false,
|
||
birthYear: 1882,
|
||
deathYear: 1944
|
||
};
|
||
|
||
const ANNA: Person = {
|
||
id: 'p-anna',
|
||
firstName: 'Anna',
|
||
lastName: 'Schmidt',
|
||
displayName: 'Anna Schmidt',
|
||
personType: 'PERSON',
|
||
familyMember: false,
|
||
provisional: false,
|
||
birthYear: 1860
|
||
};
|
||
|
||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
|
||
);
|
||
}
|
||
|
||
function mockFetchEmpty() {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||
);
|
||
}
|
||
|
||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||
|
||
function renderHost(
|
||
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||
) {
|
||
let snapshot: Snapshot = {
|
||
value: initial.value ?? '',
|
||
mentionedPersons: initial.mentionedPersons ?? []
|
||
};
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: initial.value ?? '',
|
||
initialMentions: initial.mentionedPersons ?? [],
|
||
disabled: initial.disabled ?? false,
|
||
onChange: (snap: Snapshot) => {
|
||
snapshot = snap;
|
||
}
|
||
});
|
||
return {
|
||
get snapshot() {
|
||
return snapshot;
|
||
}
|
||
};
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — rendering', () => {
|
||
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: '',
|
||
initialMentions: [],
|
||
onChange: () => {}
|
||
});
|
||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||
});
|
||
|
||
it('reflects bound initial value as visible text', async () => {
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: 'Hallo Welt',
|
||
initialMentions: [],
|
||
onChange: () => {}
|
||
});
|
||
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — typeahead', () => {
|
||
it('opens the dropdown when typing @ + query and shows results', async () => {
|
||
mockFetchWithPersons();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('hits /api/persons?q= with the typed query', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(() => {
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
expect.stringContaining('/api/persons?review=true&q=Aug')
|
||
);
|
||
});
|
||
});
|
||
|
||
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(() => {
|
||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('size=5'));
|
||
});
|
||
});
|
||
|
||
it('shows life dates next to the name in the dropdown', async () => {
|
||
mockFetchWithPersons();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('shows empty state when no persons match', async () => {
|
||
mockFetchEmpty();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||
|
||
// The visible empty-state <p> (text-ink-3) shows the copy. The persistent
|
||
// sr-only aria-live region also contains the same copy, so we scope to the
|
||
// visible element to avoid a multi-match resolution in expect.element.
|
||
await vi.waitFor(() => {
|
||
const visibleEmptyP = document.querySelector(
|
||
'[role="listbox"] p.text-ink-3'
|
||
) as HTMLElement | null;
|
||
expect(visibleEmptyP).not.toBeNull();
|
||
expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden');
|
||
});
|
||
});
|
||
|
||
it('offers a "create new person" link in the empty state', async () => {
|
||
mockFetchEmpty();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||
|
||
await vi.waitFor(async () => {
|
||
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
||
await expect.element(link).toBeVisible();
|
||
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── AC-2/3: search input drives the person fetch (debounced) ───────────────
|
||
|
||
describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
|
||
it('editing the search input fires a debounced fetch with the new query', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
// Open the dropdown so the search input is reachable.
|
||
await userEvent.type(page.getByRole('textbox'), '@');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
const fetchesBeforeSearch = fetchMock.mock.calls.length;
|
||
|
||
// `fill` simulates a single input event with the final value — sidesteps
|
||
// per-keystroke timing of userEvent.type so the test can deterministically
|
||
// assert that one input event collapses into one debounced fetch.
|
||
await page.getByRole('searchbox').fill('Walter');
|
||
|
||
await vi.waitFor(
|
||
() => {
|
||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter'));
|
||
},
|
||
{ timeout: 1000 }
|
||
);
|
||
|
||
const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
|
||
expect(fetchesAfterSearch).toBe(1);
|
||
});
|
||
|
||
it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => {
|
||
// Regression guard: a previous version of PersonMentionEditor had a
|
||
// duplicated `items()` callback in the Tiptap suggestion config that
|
||
// fetched per-keystroke in addition to the debounced search-input fetch
|
||
// (Markus & Felix round-1). To catch that regression, we must NOT
|
||
// subtract any baseline — every fetch from render onwards counts.
|
||
// Sara on PR #629 round 3.
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
// Open the dropdown, then drive the search input via fill() — sidesteps
|
||
// per-keystroke timing of userEvent.type that Sara flagged round 2.
|
||
await userEvent.type(page.getByRole('textbox'), '@');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
await page.getByRole('searchbox').fill('Walter');
|
||
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
|
||
// No baseline subtraction — count ALL /api/persons fetches since render.
|
||
// If the legacy per-keystroke items() callback returns, typing `@` alone
|
||
// would already produce one fetch and `fill('Walter')` another, breaking
|
||
// this assertion.
|
||
const personsFetches = fetchMock.mock.calls.filter(
|
||
([url]) => typeof url === 'string' && url.startsWith('/api/persons')
|
||
);
|
||
expect(personsFetches.length).toBe(1);
|
||
});
|
||
|
||
it('clearing the search input clears the list without firing a fetch', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||
});
|
||
|
||
const fetchesBeforeClear = fetchMock.mock.calls.length;
|
||
|
||
// `fill('')` atomically sets the input value in a single input event —
|
||
// same rationale as the `fill('Walter')` call above. `userEvent.clear`
|
||
// deletes per-keystroke, so intermediate values 'Au'/'A' transit through
|
||
// the bound `searchQuery` and each schedules a debounced fetch. Under
|
||
// CI-runner jitter, if two consecutive keystrokes land more than
|
||
// SEARCH_DEBOUNCE_MS (150 ms) apart, an intermediate timer fires before
|
||
// the input reaches '' and adds a phantom call — unrelated to the
|
||
// contract under test, which is "empty query => no fetch".
|
||
await page.getByRole('searchbox').fill('');
|
||
|
||
// Negative assertion: wait past the debounce window to confirm no
|
||
// trailing fetch was scheduled. Removing this wait would mask a
|
||
// re-introduction of the keystroke-driven items() fetch.
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
|
||
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
|
||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ───────────────
|
||
|
||
describe('PersonMentionEditor — whitespace-only query', () => {
|
||
it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@ ');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
|
||
// Scope to the visible empty-state <p> (text-ink-3) — the persistent
|
||
// sr-only aria-live region above contains the same copy.
|
||
const visibleEmptyP = document.querySelector(
|
||
'[role="listbox"] p.text-ink-3'
|
||
) as HTMLElement | null;
|
||
expect(visibleEmptyP).not.toBeNull();
|
||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — stale-response race', () => {
|
||
it('discards a stale response that resolves after the search has been cleared', async () => {
|
||
let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
|
||
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
|
||
(r) => {
|
||
resolveFetch = r;
|
||
}
|
||
);
|
||
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
// Open the dropdown and let the debounce fire so a fetch is in flight.
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await vi.waitFor(() => {
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
expect.stringContaining('/api/persons?review=true&q=Aug')
|
||
);
|
||
});
|
||
|
||
// Clear the search input *before* the fetch resolves.
|
||
await userEvent.clear(page.getByRole('searchbox'));
|
||
await expect.element(page.getByRole('searchbox')).toHaveValue('');
|
||
|
||
// The stale fetch now resolves with persons. The dropdown must stay empty.
|
||
resolveFetch({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
|
||
// Flush pending Svelte reactivity so any (non-)update from the stale
|
||
// fetch resolution has landed before we assert. expect.element already
|
||
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.
|
||
await tick();
|
||
|
||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ─── Server failure characterization (Sara #2 on PR #629) ───────────────────
|
||
|
||
describe('PersonMentionEditor — server failure', () => {
|
||
it('on 500 response keeps the dropdown open with the empty-state copy (silent failure pinned; distinct error UX tracked separately)', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({}) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
|
||
// Pins current silent-failure behaviour. The day someone implements a
|
||
// distinct error UX (toast / "Suche fehlgeschlagen" copy), this test
|
||
// goes red and forces them to update the assertion. Scope to the
|
||
// visible <p> (text-ink-3) — the persistent sr-only live region
|
||
// above contains the same copy.
|
||
const visibleEmptyP = document.querySelector(
|
||
'[role="listbox"] p.text-ink-3'
|
||
) as HTMLElement | null;
|
||
expect(visibleEmptyP).not.toBeNull();
|
||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
||
});
|
||
|
||
it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => {
|
||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError'));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
|
||
const visibleEmptyP = document.querySelector(
|
||
'[role="listbox"] p.text-ink-3'
|
||
) as HTMLElement | null;
|
||
expect(visibleEmptyP).not.toBeNull();
|
||
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
|
||
});
|
||
});
|
||
|
||
// ─── onExit cancels pending debounce (Felix #1 on PR #629) ───────────────────
|
||
|
||
describe('PersonMentionEditor — onExit cancels pending debounce', () => {
|
||
it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
renderHost();
|
||
|
||
// Open the dropdown by typing @ + a query in the editor.
|
||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
// Wait for any in-flight fetch from opening the dropdown to settle.
|
||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||
const fetchesBeforeEscape = fetchMock.mock.calls.length;
|
||
|
||
// Freeze setTimeout so the 150 ms debounce cannot fire before Escape
|
||
// triggers onExit. We install fake timers only now — after the setup
|
||
// above — so that vi.waitFor()'s real-timer polling still worked.
|
||
vi.useFakeTimers();
|
||
try {
|
||
// fill() dispatches the input event synchronously via CDP; by the
|
||
// time the await resolves, onSearch('Walter') has run and the fake
|
||
// debounce timer is set.
|
||
await page.getByRole('searchbox').fill('Walter');
|
||
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
|
||
(page.getByRole('textbox').element() as HTMLElement).focus();
|
||
await userEvent.keyboard('{Escape}');
|
||
// onExit has now called debouncedSearch.cancel(). Advance past the
|
||
// debounce window — the cancelled timer must not fire.
|
||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
|
||
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
|
||
const walterFetches = newFetches.filter(
|
||
([url]) => typeof url === 'string' && url.includes('q=Walter')
|
||
);
|
||
expect(walterFetches.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
||
|
||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
||
it('prefills the dropdown search input with the text typed after @', async () => {
|
||
mockFetchEmpty();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@WdG');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||
|
||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||
it('stores the typed query as displayName, not the person DB name', async () => {
|
||
mockFetchWithPersons();
|
||
const host = renderHost();
|
||
|
||
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||
});
|
||
|
||
// MentionDropdown handles selection via onmousedown (not onclick) to prevent
|
||
// blurring the editor before the selection fires. userEvent.click() via CDP
|
||
// does not reliably trigger Svelte 5's onmousedown handler when TipTap is
|
||
// mounted — dispatch the MouseEvent directly from browser JS instead.
|
||
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).toHaveLength(1);
|
||
expect(host.snapshot.mentionedPersons[0]).toEqual({
|
||
personId: 'p-aug',
|
||
displayName: 'Aug' // typed text, not "Auguste Raddatz"
|
||
});
|
||
});
|
||
});
|
||
|
||
it('regression: text value contains the typed query, not the full DB name', 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();
|
||
});
|
||
|
||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||
|
||
await vi.waitFor(() => {
|
||
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
|
||
expect(host.snapshot.value).toContain('@Aug');
|
||
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
|
||
});
|
||
});
|
||
|
||
it('pushes {personId, displayName} into mentionedPersons sidecar', 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('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => {
|
||
// CWE-400 amplification: the dropdown clips its search input + mirror at
|
||
// 100 chars (Nora #1), but the host editor was passing the unclipped
|
||
// renderProps.query straight through to displayName — so a 105-char
|
||
// @-suffix in the editor could insert a 105-char displayName into the
|
||
// sidecar even though the dropdown only searched the first 100.
|
||
mockFetchWithPersons();
|
||
const host = renderHost();
|
||
|
||
// Type @ + 105 'A' chars in the contenteditable. The renderProps.query
|
||
// fed into the command callback derives from the editor text after `@`,
|
||
// not the dropdown's searchbox — so we must drive the editor.
|
||
await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105));
|
||
|
||
// The mocked /api/persons returns AUGUSTE for any query — wait for it.
|
||
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).toHaveLength(1);
|
||
// Tight assertion: input is 105 chars, cap is exactly 100. Using
|
||
// `toHaveLength(100)` discriminates "clip works" from "clip works
|
||
// AND nothing weakened it to e.g. 95". Sara on PR #629 round 4.
|
||
expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100);
|
||
});
|
||
});
|
||
|
||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||
mockFetchWithPersons();
|
||
const host = renderHost({
|
||
value: '@Aug ',
|
||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
||
});
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||
});
|
||
|
||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — keyboard navigation', () => {
|
||
it('Enter selects the highlighted result', async () => {
|
||
mockFetchWithPersons();
|
||
const host = renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||
});
|
||
|
||
await userEvent.keyboard('{Enter}');
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
it('ArrowDown moves the highlight to the next result', async () => {
|
||
mockFetchWithPersons();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||
});
|
||
|
||
await userEvent.keyboard('{ArrowDown}');
|
||
|
||
await vi.waitFor(async () => {
|
||
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||
});
|
||
});
|
||
|
||
it('Escape closes the dropdown without inserting', async () => {
|
||
mockFetchWithPersons();
|
||
const host = renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||
});
|
||
|
||
await userEvent.keyboard('{Escape}');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||
});
|
||
|
||
expect(host.snapshot.mentionedPersons).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||
|
||
describe('PersonMentionEditor — disabled state', () => {
|
||
it('sets contenteditable=false on the editor when disabled', async () => {
|
||
renderHost({ value: 'Bestehender Text', disabled: true });
|
||
|
||
await vi.waitFor(() => {
|
||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||
expect(textbox).not.toBeNull();
|
||
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||
});
|
||
});
|
||
|
||
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||
renderHost({ disabled: true });
|
||
|
||
await vi.waitFor(() => {
|
||
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||
expect(wrapper).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||
renderHost({ disabled: false });
|
||
|
||
await vi.waitFor(() => {
|
||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||
expect(textbox).not.toBeNull();
|
||
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — XSS resistance', () => {
|
||
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
||
// A historical sidecar entry whose displayName contains an HTML payload
|
||
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
||
// returns the @-prefixed string as the third tuple entry, which
|
||
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
||
const maliciousMention: PersonMention = {
|
||
personId: '00000000-0000-0000-0000-000000000001',
|
||
displayName: '<img src=x onerror=alert(1)>'
|
||
};
|
||
|
||
renderHost({
|
||
value: '@<img src=x onerror=alert(1)>',
|
||
mentionedPersons: [maliciousMention]
|
||
});
|
||
|
||
await vi.waitFor(() => {
|
||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||
expect(textbox).not.toBeNull();
|
||
// No element from the malicious payload should have appeared as a real
|
||
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
||
// paragraphs — that is internal markup and never carries user attrs;
|
||
// guard against the injection by checking the user-controlled attrs.)
|
||
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
||
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
||
expect(textbox!.querySelector('script')).toBeNull();
|
||
// The payload should appear as visible text content instead.
|
||
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── Placeholder behavior ─────────────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — placeholder behavior', () => {
|
||
it('sets data-placeholder on the inner element when editor is empty', async () => {
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: '',
|
||
initialMentions: [],
|
||
placeholder: 'Gib Text ein...',
|
||
onChange: () => {}
|
||
});
|
||
await vi.waitFor(() => {
|
||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||
expect(inner).not.toBeNull();
|
||
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
|
||
});
|
||
});
|
||
|
||
it('omits data-placeholder on the inner element when editor has content', async () => {
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: 'Bestehender Text',
|
||
initialMentions: [],
|
||
placeholder: 'Gib Text ein...',
|
||
onChange: () => {}
|
||
});
|
||
await vi.waitFor(() => {
|
||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||
expect(inner).not.toBeNull();
|
||
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── i18n message content ─────────────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — i18n message content', () => {
|
||
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
|
||
expect(m.transcription_block_placeholder()).toContain('@');
|
||
});
|
||
});
|
||
|
||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||
|
||
describe('PersonMentionEditor — touch target', () => {
|
||
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
|
||
mockFetchWithPersons();
|
||
renderHost();
|
||
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option').first()).toBeVisible();
|
||
});
|
||
|
||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||
expect(option).not.toBeNull();
|
||
expect(option.className).toContain('min-h-[44px]');
|
||
});
|
||
});
|
||
|
||
// ─── #628: re-edit an existing @mention via the pencil affordance ─────────────
|
||
|
||
describe('PersonMentionEditor — #628 re-edit pencil', () => {
|
||
// A saved mention seeded into the editor: text "@Aug " + sidecar. The typed
|
||
// displayName is "Aug" (short form, #380 AC-1), distinct from the DB name.
|
||
const SAVED = { personId: 'p-aug', displayName: 'Aug' };
|
||
|
||
function editPencil() {
|
||
return page.getByRole('button', { name: m.person_mention_edit_label() });
|
||
}
|
||
|
||
async function renderSavedMention() {
|
||
const host = renderHost({ value: '@Aug ', mentionedPersons: [SAVED] });
|
||
await expect.element(editPencil()).toBeInTheDocument();
|
||
return host;
|
||
}
|
||
|
||
async function pencilElement(): Promise<HTMLElement> {
|
||
return (await editPencil().element()) as HTMLElement;
|
||
}
|
||
|
||
function clickPencil(btn: HTMLElement) {
|
||
// Native dispatch — CDP userEvent.click is unreliable for handlers attached
|
||
// imperatively inside a ProseMirror NodeView (same rationale as the option
|
||
// mousedown dispatch elsewhere in this file).
|
||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
}
|
||
|
||
async function pickOption(name: RegExp) {
|
||
const el = (await page.getByRole('option', { name }).element()) as HTMLElement;
|
||
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||
}
|
||
|
||
// AC-1 ----------------------------------------------------------------------
|
||
|
||
it('renders an edit pencil on a saved mention, labelled via aria-label', async () => {
|
||
await renderSavedMention();
|
||
await expect.element(editPencil()).toBeInTheDocument();
|
||
});
|
||
|
||
it('the pencil is keyboard-focusable (tabindex 0, focus lands on it)', async () => {
|
||
await renderSavedMention();
|
||
const btn = await pencilElement();
|
||
expect(btn.getAttribute('tabindex')).toBe('0');
|
||
btn.focus();
|
||
expect(document.activeElement).toBe(btn);
|
||
});
|
||
|
||
it('hides the pencil by default and reveals it on hover + focus-within (opacity swap)', async () => {
|
||
await renderSavedMention();
|
||
const btn = await pencilElement();
|
||
// Reveal is a class-driven instant opacity swap (no Tailwind CSS in the
|
||
// component test env — assert the mechanism structurally, as the existing
|
||
// touch-target test does for min-h-[44px]).
|
||
expect(btn.className).toContain('opacity-0');
|
||
expect(btn.className).toContain('group-hover/mention:opacity-100');
|
||
expect(btn.className).toContain('group-focus-within/mention:opacity-100');
|
||
});
|
||
|
||
it('renders the pencil in an always-present fixed-width slot (no reflow)', async () => {
|
||
await renderSavedMention();
|
||
const slot = document.querySelector('.mention-edit-slot') as HTMLElement | null;
|
||
expect(slot).not.toBeNull();
|
||
expect(slot!.className).toContain('w-4');
|
||
});
|
||
|
||
// AC-2 ----------------------------------------------------------------------
|
||
|
||
it('opens the dropdown pre-filled with the stored displayName when the pencil is clicked', async () => {
|
||
mockFetchEmpty();
|
||
await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||
});
|
||
});
|
||
|
||
it('opens the dropdown when the pencil is activated by keyboard (Enter)', async () => {
|
||
mockFetchEmpty();
|
||
await renderSavedMention();
|
||
const btn = await pencilElement();
|
||
btn.focus();
|
||
btn.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||
);
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
|
||
});
|
||
});
|
||
|
||
// AC-3 ----------------------------------------------------------------------
|
||
|
||
it('relinks in place: picking a different person swaps data-person-id', async () => {
|
||
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
|
||
await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
await pickOption(/Anna Schmidt/);
|
||
|
||
await vi.waitFor(() => {
|
||
const token = document.querySelector('[data-type="mention"]') as HTMLElement;
|
||
expect(token.getAttribute('data-person-id')).toBe('p-anna');
|
||
});
|
||
});
|
||
|
||
it('relink preserves the displayed token text exactly (only personId changes)', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
await pickOption(/Anna Schmidt/);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
|
||
});
|
||
});
|
||
|
||
// Serializer regression — text byte-identical, only personId swapped --------
|
||
|
||
it('serializer regression: re-link keeps the text byte-identical, swaps only personId', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
await pickOption(/Anna Schmidt/);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.value).toBe('@Aug ');
|
||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
|
||
});
|
||
});
|
||
|
||
// AC-5 — edited-then-picked (highest value): personId updates, text invariant
|
||
|
||
it('AC-5: editing the search input then picking sets the new personId', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
await page.getByRole('searchbox').fill('Anna');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
await pickOption(/Anna Schmidt/);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
|
||
});
|
||
});
|
||
|
||
it('AC-5: editing the search input then picking keeps the ORIGINAL displayName', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
await page.getByRole('searchbox').fill('Anna');
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
await pickOption(/Anna Schmidt/);
|
||
|
||
await vi.waitFor(() => {
|
||
// The #380 AC-1 invariant wins over the edited search input.
|
||
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
|
||
expect(host.snapshot.value).toBe('@Aug ');
|
||
expect(host.snapshot.value).not.toContain('@Anna');
|
||
});
|
||
});
|
||
|
||
// AC-4 — dismiss leaves the node byte-identical on BOTH close paths ---------
|
||
|
||
function dismissButton() {
|
||
return page.getByRole('button', { name: m.person_mention_dismiss_label() });
|
||
}
|
||
|
||
it('AC-4: Escape closes the re-edit dropdown leaving the node byte-identical', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||
search.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||
);
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||
});
|
||
expect(host.snapshot.value).toBe('@Aug ');
|
||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||
});
|
||
|
||
it('AC-4: the visible dismiss control closes the re-edit dropdown leaving the node byte-identical', async () => {
|
||
mockFetchWithPersons();
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await expect.element(dismissButton()).toBeVisible();
|
||
|
||
const dismissEl = (await dismissButton().element()) as HTMLElement;
|
||
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||
});
|
||
expect(host.snapshot.value).toBe('@Aug ');
|
||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||
});
|
||
|
||
// AC-9 parity — the re-edit dropdown is keyboard-operable on its own --------
|
||
|
||
it('re-edit open focuses the search input so keyboard users land in the field', async () => {
|
||
mockFetchEmpty();
|
||
await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('searchbox')).toBeVisible();
|
||
});
|
||
|
||
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||
await vi.waitFor(() => {
|
||
expect(document.activeElement).toBe(search);
|
||
});
|
||
});
|
||
|
||
it('re-edit dropdown is keyboard-navigable: ArrowDown then Enter relinks', async () => {
|
||
mockFetchWithPersons(); // [AUGUSTE (p-aug, highlighted), ANNA (p-anna)]
|
||
const host = await renderSavedMention();
|
||
clickPencil(await pencilElement());
|
||
await vi.waitFor(async () => {
|
||
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
|
||
});
|
||
|
||
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
|
||
search.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
||
);
|
||
search.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||
);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
|
||
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
|
||
});
|
||
});
|
||
|
||
// The × control is shared with the fresh-@ dropdown ------------------------
|
||
|
||
it('the fresh-@ dropdown also exposes the shared dismiss control', async () => {
|
||
mockFetchWithPersons();
|
||
renderHost();
|
||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||
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();
|
||
});
|
||
});
|
||
|
||
// ─── #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);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── #628 NFR a11y: editing context announced via the existing live region ───
|
||
|
||
describe('PersonMentionEditor — #628 editing announce', () => {
|
||
it('re-edit open announces the editing context through the single persistent live region', async () => {
|
||
mockFetchEmpty();
|
||
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||
await vi.waitFor(() =>
|
||
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
|
||
);
|
||
(document.querySelector('[data-type="mention"] button') as HTMLElement).dispatchEvent(
|
||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||
);
|
||
|
||
await vi.waitFor(() => {
|
||
const liveRegions = document.querySelectorAll('[role="listbox"] [aria-live="polite"]');
|
||
// Exactly ONE live region (no second announcer — avoids double-announce).
|
||
expect(liveRegions.length).toBe(1);
|
||
expect(liveRegions[0].textContent ?? '').toContain(
|
||
m.person_mention_editing_announce({ displayName: 'Aug' })
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── #628 review fixes: hidden-pencil hit-testing + mid-session disable ───────
|
||
|
||
describe('PersonMentionEditor — #628 review fixes', () => {
|
||
it('keeps the hidden pencil out of hit-testing (pointer-events-none until revealed)', async () => {
|
||
// opacity-0 alone still hit-tests, and the 44px button overhangs adjacent
|
||
// text — so a click in the gap between mentions would otherwise land on the
|
||
// invisible pencil and open the dropdown (AC-8). It must be pointer-events-none
|
||
// while hidden and only become clickable together with the opacity reveal.
|
||
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
|
||
await vi.waitFor(() =>
|
||
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
|
||
);
|
||
const btn = document.querySelector('[data-type="mention"] button') as HTMLElement;
|
||
expect(btn.className).toContain('pointer-events-none');
|
||
expect(btn.className).toContain('group-hover/mention:pointer-events-auto');
|
||
expect(btn.className).toContain('group-focus-within/mention:pointer-events-auto');
|
||
});
|
||
|
||
it('re-syncs the pencil to inert when the editor is disabled mid-session', async () => {
|
||
// editor.setEditable() emits "update", not a transaction — the NodeView must
|
||
// listen on "update" or a runtime disable flip leaves the pencil stale.
|
||
let setDisabled!: (value: boolean) => void;
|
||
render(PersonMentionEditorHost, {
|
||
initialValue: '@Aug ',
|
||
initialMentions: [{ personId: 'p-aug', displayName: 'Aug' }],
|
||
disabled: false,
|
||
onChange: () => {},
|
||
onReady: (api: { setDisabled: (value: boolean) => void }) => {
|
||
setDisabled = api.setDisabled;
|
||
}
|
||
});
|
||
|
||
await vi.waitFor(() => {
|
||
const btn = document.querySelector(
|
||
'[data-type="mention"] button'
|
||
) as HTMLButtonElement | null;
|
||
expect(btn).not.toBeNull();
|
||
expect(btn!.disabled).toBe(false);
|
||
});
|
||
|
||
setDisabled(true);
|
||
|
||
await vi.waitFor(() => {
|
||
const btn = document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
|
||
expect(btn.disabled).toBe(true);
|
||
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
||
expect(btn.tabIndex).toBe(-1);
|
||
});
|
||
});
|
||
});
|