feat(transcription): re-edit existing @mention by pre-filling the search input (#628) #717

Merged
marcel merged 9 commits from feat/issue-628-mention-reedit into main 2026-06-03 07:55:30 +02:00
Showing only changes of commit 7f141725f8 - Show all commits

View File

@@ -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);
});
});
});