feat(transcription): decouple @mention display text from person search (#380) #629
@@ -9,7 +9,7 @@ import type { PersonMention } from '$lib/shared/types';
|
|||||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||||
import { debounce } from '$lib/shared/utils/debounce';
|
import { debounce } from '$lib/shared/utils/debounce';
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
import { SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
|
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
@@ -225,14 +225,22 @@ onMount(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateState = (renderProps: LooseRenderProps) => {
|
const updateState = (renderProps: LooseRenderProps) => {
|
||||||
|
// Clip once here so both the inserted displayName and the
|
||||||
|
// dropdown's editor-mirror see the same value. The dropdown
|
||||||
|
// already clips the mirror (Nora #1 CWE-400), but without
|
||||||
|
// clipping at the command boundary an unclipped query would
|
||||||
|
// still flow through as the inserted displayName — visible
|
||||||
|
// UI divergence between "what I searched" and "what was
|
||||||
|
// inserted". Felix #3 on PR #629.
|
||||||
|
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
|
||||||
// AC-1: pass typed query as displayName, not person.displayName
|
// AC-1: pass typed query as displayName, not person.displayName
|
||||||
dropdownState.command = (item: Person) =>
|
dropdownState.command = (item: Person) =>
|
||||||
renderProps.command({
|
renderProps.command({
|
||||||
personId: item.id,
|
personId: item.id,
|
||||||
displayName: renderProps.query
|
displayName: clippedQuery
|
||||||
});
|
});
|
||||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||||
dropdownState.editorQuery = renderProps.query;
|
dropdownState.editorQuery = clippedQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -473,6 +473,36 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(host.snapshot.mentionedPersons[0].displayName.length).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost({
|
const host = renderHost({
|
||||||
|
|||||||
Reference in New Issue
Block a user