Skip to content

Commit 12247ac

Browse files
committed
Fix token-renderer page issue causing failing integ tests and enhance unit test coverage further
1 parent a0add0f commit 12247ac

File tree

3 files changed

+169
-1
lines changed

3 files changed

+169
-1
lines changed

pages/prompt-input/token-renderer.page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export default function TokenRendererPage() {
7272

7373
const extractFromDOM = () => {
7474
if (editorRef.current) {
75-
const result = extractTokensFromDOM(editorRef.current, menus);
75+
const result = extractTokensFromDOM(editorRef.current, menus, portalContainersRef.current);
7676
setExtracted(result);
7777
}
7878
};

src/prompt-input/__tests__/dom-utils.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,65 @@ describe('findElement with tokenId', () => {
506506
expect(found).toBeNull();
507507
});
508508
});
509+
510+
describe('normalizeCaretIntoTrigger - trigger offset 0 nudge', () => {
511+
function createTrigger(id: string, text: string): HTMLElement {
512+
const span = document.createElement('span');
513+
span.setAttribute('data-type', ElementType.Trigger);
514+
span.id = id;
515+
span.textContent = text;
516+
return span;
517+
}
518+
519+
test('nudges caret from offset 0 inside trigger text to paragraph level before trigger', () => {
520+
const el = document.createElement('div');
521+
const p = document.createElement('p');
522+
const textBefore = document.createTextNode('hello ');
523+
const trigger = createTrigger('trig-1', '@bob');
524+
p.appendChild(textBefore);
525+
p.appendChild(trigger);
526+
el.appendChild(p);
527+
document.body.appendChild(el);
528+
529+
// Place caret at offset 0 inside the trigger's text node
530+
const triggerText = trigger.childNodes[0];
531+
const range = document.createRange();
532+
range.setStart(triggerText, 0);
533+
range.collapse(true);
534+
const sel = window.getSelection()!;
535+
sel.removeAllRanges();
536+
sel.addRange(range);
537+
538+
normalizeCaretIntoTrigger(el);
539+
540+
const newRange = sel.getRangeAt(0);
541+
// Should be at paragraph level, before the trigger element
542+
expect(newRange.startContainer).toBe(p);
543+
expect(newRange.startOffset).toBe(1); // After textBefore, before trigger
544+
document.body.removeChild(el);
545+
});
546+
547+
test('does not nudge when trigger is the first child (idx === 0)', () => {
548+
const el = document.createElement('div');
549+
const p = document.createElement('p');
550+
const trigger = createTrigger('trig-1', '@bob');
551+
p.appendChild(trigger);
552+
el.appendChild(p);
553+
document.body.appendChild(el);
554+
555+
const triggerText = trigger.childNodes[0];
556+
const range = document.createRange();
557+
range.setStart(triggerText, 0);
558+
range.collapse(true);
559+
const sel = window.getSelection()!;
560+
sel.removeAllRanges();
561+
sel.addRange(range);
562+
563+
normalizeCaretIntoTrigger(el);
564+
565+
// idx is 0, so no nudge — caret stays in trigger text
566+
const newRange = sel.getRangeAt(0);
567+
expect(newRange.startContainer).toBe(triggerText);
568+
document.body.removeChild(el);
569+
});
570+
});

src/prompt-input/__tests__/prompt-input-token-mode.test.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7137,3 +7137,109 @@ describe('paste multiline over full selection', () => {
71377137
expect(range.startOffset).toBe(3);
71387138
});
71397139
});
7140+
7141+
describe('paste partial selection replacement', () => {
7142+
function pasteText(element: HTMLElement, text: string) {
7143+
fireEvent.paste(element, {
7144+
clipboardData: {
7145+
getData: (type: string) => (type === 'text/plain' ? text : ''),
7146+
types: ['text/plain'],
7147+
items: [],
7148+
files: [],
7149+
},
7150+
});
7151+
}
7152+
7153+
test('paste replaces partial text selection within a single line', () => {
7154+
const ref = React.createRef<PromptInputProps.Ref>();
7155+
const onChange = jest.fn();
7156+
renderStatefulTokenMode({
7157+
props: { tokens: [{ type: 'text', value: 'hello world' }], onChange },
7158+
ref,
7159+
});
7160+
act(() => {
7161+
ref.current!.focus();
7162+
});
7163+
act(() => {
7164+
ref.current!.setSelectionRange(6, 11);
7165+
});
7166+
const el = document.querySelector('[role="textbox"]') as HTMLElement;
7167+
act(() => {
7168+
pasteText(el, 'universe');
7169+
});
7170+
expect(onChange).toHaveBeenCalled();
7171+
const lastValue = onChange.mock.calls[onChange.mock.calls.length - 1][0].detail.value;
7172+
expect(lastValue).toContain('hello');
7173+
expect(lastValue).toContain('universe');
7174+
expect(lastValue).not.toContain('world');
7175+
});
7176+
7177+
test('paste multiline over partial selection in multiline content', () => {
7178+
const ref = React.createRef<PromptInputProps.Ref>();
7179+
const onChange = jest.fn();
7180+
renderStatefulTokenMode({
7181+
props: {
7182+
tokens: [
7183+
{ type: 'text', value: 'aaa' },
7184+
{ type: 'break', value: '\n' },
7185+
{ type: 'text', value: 'bbb' },
7186+
{ type: 'break', value: '\n' },
7187+
{ type: 'text', value: 'ccc' },
7188+
],
7189+
onChange,
7190+
},
7191+
ref,
7192+
});
7193+
act(() => {
7194+
ref.current!.focus();
7195+
});
7196+
// Select middle line "bbb" (positions 4-7)
7197+
act(() => {
7198+
ref.current!.setSelectionRange(4, 7);
7199+
});
7200+
const el = document.querySelector('[role="textbox"]') as HTMLElement;
7201+
act(() => {
7202+
pasteText(el, 'xxx\nyyy');
7203+
});
7204+
expect(onChange).toHaveBeenCalled();
7205+
const lastTokens = onChange.mock.calls[onChange.mock.calls.length - 1][0].detail.tokens;
7206+
const texts = lastTokens.filter((t: any) => t.type === 'text').map((t: any) => t.value);
7207+
// "aaa" should remain, "bbb" replaced by "xxx\nyyy", "ccc" should remain
7208+
expect(texts.join(' ')).toContain('aaa');
7209+
expect(texts.join(' ')).toContain('ccc');
7210+
expect(texts.join(' ')).not.toContain('bbb');
7211+
});
7212+
7213+
test('paste is no-op when readOnly', () => {
7214+
const ref = React.createRef<PromptInputProps.Ref>();
7215+
const onChange = jest.fn();
7216+
renderStatefulTokenMode({
7217+
props: { tokens: [{ type: 'text', value: 'original' }], onChange, readOnly: true },
7218+
ref,
7219+
});
7220+
const callsBefore = onChange.mock.calls.length;
7221+
const el = document.querySelector('[role="textbox"]') as HTMLElement;
7222+
act(() => {
7223+
pasteText(el, 'injected');
7224+
});
7225+
expect(onChange.mock.calls.length).toBe(callsBefore);
7226+
});
7227+
7228+
test('paste empty string is no-op', () => {
7229+
const ref = React.createRef<PromptInputProps.Ref>();
7230+
const onChange = jest.fn();
7231+
renderStatefulTokenMode({
7232+
props: { tokens: [{ type: 'text', value: 'original' }], onChange },
7233+
ref,
7234+
});
7235+
act(() => {
7236+
ref.current!.focus();
7237+
});
7238+
const callsBefore = onChange.mock.calls.length;
7239+
const el = document.querySelector('[role="textbox"]') as HTMLElement;
7240+
act(() => {
7241+
pasteText(el, '');
7242+
});
7243+
expect(onChange.mock.calls.length).toBe(callsBefore);
7244+
});
7245+
});

0 commit comments

Comments
 (0)