test(transcription): AC-7 disabled, AC-8 no-mention, security clip/provenance (#628)
- AC-7: disabled editor → pencil is disabled + aria-disabled + tabindex -1, and neither keyboard nor pointer activation mounts a dropdown (WCAG 2.1.1, not just pointer-events-none). - AC-8: plain text shows no pencil/dropdown; two adjacent mentions each keep one pencil with no spurious gap pencil and no auto-open; a doc-start mention still renders its pencil. - Security: an oversized stored displayName clips the search query to 100 chars while the preserved node text stays full-length; re-link sources personId solely from the picked Person (p-anna), never the reflected/clipped text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1177,3 +1177,135 @@ describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => {
|
|||||||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user