char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
bdffafa5d
char/aliceai-t5-moe
1// Guards the clipboard contract of the ChatFormInputRich: 2// copy/cut expose the markdown SOURCE of the selection (each badge 3// contributes its full `[name](file://...)` link) and pasting such 4// markdown re-renders the badges. 5 6import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte' ; 7import { rangeToTextOffset , serializeContent , textOffsetToRange } from '$lib/utils' ; 8import { tick } from 'svelte' ; 9import { describe , expect , it , vi } from 'vitest' ; 10import { userEvent } from 'vitest/browser' ; 11import { render } from 'vitest-browser-svelte' ; 12 13const SOURCE = 'hello [docs](file:///a/b) world' ; 14const BADGE_SELECTOR = '[data-mention-badge="true"]' ; 15 16function editableIn ( container :HTMLElement ) :HTMLElement { 17const el = container . querySelector ( '[role="textbox"]' ); 18 19if ( ! ( el instanceof HTMLElement )) throw new Error ( 'ChatFormInputRich not rendered' ); 20 21return el ; 22} 23 24function setSelection ( root :HTMLElement , place :( range :Range , root :HTMLElement ) => void ) { 25const range = document . createRange (); 26 27place ( range , root ); 28const selection = window . getSelection (); 29 30if ( ! selection ) throw new Error ( 'no selection' ); 31 32selection . removeAllRanges (); 33selection . addRange ( range ); 34} 35 36function clipboardEvent ( type :'copy' | 'cut' | 'paste' , text = '' ) { 37const data = new DataTransfer (); 38 39if ( text ) data . setData ( 'text/plain' , text ); 40 41const event = new ClipboardEvent ( type , { bubbles :true , cancelable :true , clipboardData :data }); 42 43return { data, event}; 44} 45 46describe ( 'ChatFormInputRich clipboard' , () => { 47it ( 'copy exposes the markdown source of the selection' , async () => { 48const { container} = render ( ChatFormInputRich , { value :SOURCE }); 49 50await tick (); 51 52const root = editableIn ( container ); 53 54setSelection ( root , ( range ) => range . selectNodeContents ( root )); 55 56const { data, event} = clipboardEvent ( 'copy' ); 57 58root . dispatchEvent ( event ); 59 60expect ( event . defaultPrevented ). toBe ( true ); 61expect ( data . getData ( 'text/plain' )). toBe ( SOURCE ); 62}); 63 64it ( 'cut exposes the markdown source and removes the slice' , async () => { 65const { container} = render ( ChatFormInputRich , { value :SOURCE }); 66 67await tick (); 68 69const root = editableIn ( container ); 70 71setSelection ( root , ( range ) => { 72const badge = root . querySelector ( BADGE_SELECTOR ); 73 74if ( ! badge ) throw new Error ( 'badge not rendered' ); 75 76range . setStartBefore ( badge ); 77range . setEndAfter ( badge ); 78}); 79 80const { data, event} = clipboardEvent ( 'cut' ); 81 82root . dispatchEvent ( event ); 83 84expect ( event . defaultPrevented ). toBe ( true ); 85expect ( data . getData ( 'text/plain' )). toBe ( '[docs](file:///a/b)' ); 86expect ( root . querySelector ( BADGE_SELECTOR )). toBeNull (); 87expect ( root . textContent ). toBe ( 'hello world' ); 88}); 89 90it ( 'paste of markdown mention links re-renders badges' , async () => { 91const { container} = render ( ChatFormInputRich , { value :'hello ' }); 92 93await tick (); 94 95const root = editableIn ( container ); 96 97root . focus (); 98setSelection ( root , ( range ) => { 99range . selectNodeContents ( root ); 100range . collapse ( false ); 101}); 102 103const { event} = clipboardEvent ( 'paste' , '[docs](file:///a/b) world' ); 104 105root . dispatchEvent ( event ); 106await tick (); 107 108expect ( event . defaultPrevented ). toBe ( true ); 109const badge = root . querySelector ( BADGE_SELECTOR ); 110 111expect ( badge ). not . toBeNull (); 112expect ( badge ! . getAttribute ( 'data-mention-name' )). toBe ( 'docs' ); 113expect ( root . textContent ). toContain ( 'world' ); 114}); 115 116it ( 'paste without mention links keeps the DOM untouched' , async () => { 117const { container} = render ( ChatFormInputRich , { value :'hello ' }); 118 119await tick (); 120 121const root = editableIn ( container ); 122 123root . focus (); 124setSelection ( root , ( range ) => { 125range . selectNodeContents ( root ); 126range . collapse ( false ); 127}); 128const firstChild = root . firstChild ; 129const { event} = clipboardEvent ( 'paste' , 'plain text' ); 130 131root . dispatchEvent ( event ); 132await tick (); 133 134expect ( event . defaultPrevented ). toBe ( true ); 135expect ( root . querySelector ( BADGE_SELECTOR )). toBeNull (); 136// no rebuild: the live text node is the same instance 137expect ( root . firstChild ). toBe ( firstChild ); 138}); 139}); 140 141describe ( 'ChatFormInputRich code spans' , () => { 142it ( 'renders inline code from the initial value' , async () => { 143const { container} = render ( ChatFormInputRich , { value :'run `npm test` now' }); 144 145await tick (); 146 147const root = editableIn ( container ); 148const code = root . querySelector ( 'code[data-code-token="code_inline"]' ); 149 150expect ( code ). not . toBeNull (); 151expect ( code ! . textContent ). toBe ( '`npm test`' ); 152}); 153 154it ( 'renders a fenced code block with a language' , async () => { 155const source = 'before\n```js\nconst a = 1;\n```\nafter' ; 156const { container} = render ( ChatFormInputRich , { value :source }); 157 158await tick (); 159 160const root = editableIn ( container ); 161const code = root . querySelector ( 'code[data-code-token="code_block"]' ); 162 163expect ( code ). not . toBeNull (); 164expect ( code ! . textContent ). toBe ( '```js\nconst a = 1;\n```' ); 165}); 166 167it ( 'copy exposes the markdown source of a selection spanning code' , async () => { 168const source = 'run `npm test` now' ; 169const { container} = render ( ChatFormInputRich , { value :source }); 170 171await tick (); 172 173const root = editableIn ( container ); 174 175setSelection ( root , ( range ) => range . selectNodeContents ( root )); 176 177const { data, event} = clipboardEvent ( 'copy' ); 178 179root . dispatchEvent ( event ); 180 181expect ( event . defaultPrevented ). toBe ( true ); 182expect ( data . getData ( 'text/plain' )). toBe ( source ); 183}); 184 185it ( 'paste of a code span renders the styled element' , async () => { 186const { container} = render ( ChatFormInputRich , { value :'run ' }); 187 188await tick (); 189 190const root = editableIn ( container ); 191 192root . focus (); 193setSelection ( root , ( range ) => { 194range . selectNodeContents ( root ); 195range . collapse ( false ); 196}); 197 198const { event} = clipboardEvent ( 'paste' , '`npm test` now' ); 199 200root . dispatchEvent ( event ); 201await tick (); 202 203expect ( event . defaultPrevented ). toBe ( true ); 204const code = root . querySelector ( 'code[data-code-token="code_inline"]' ); 205 206expect ( code ). not . toBeNull (); 207expect ( code ! . textContent ). toBe ( '`npm test`' ); 208expect ( root . textContent ). toContain ( 'now' ); 209}); 210 211it ( 'highlights a fenced block content and stays byte-exact' , async () => { 212const source = '```js\nconst a = 1;\n```' ; 213const { container} = render ( ChatFormInputRich , { value :source }); 214 215await tick (); 216 217const root = editableIn ( container ); 218const code = root . querySelector ( 'code[data-code-token="code_block"]' ); 219 220expect ( code ). not . toBeNull (); 221expect ( code ! . querySelector ( '.hljs-keyword' )). not . toBeNull (); 222expect ( code ! . textContent ). toBe ( source ); 223}); 224 225it ( 'does not highlight inline code' , async () => { 226const { container} = render ( ChatFormInputRich , { value :'run `const` now' }); 227 228await tick (); 229 230const root = editableIn ( container ); 231 232expect ( root . querySelector ( '[class*="hljs-"]' )). toBeNull (); 233}); 234}); 235 236describe ( 'ChatFormInputRich code block escape hatches' , () => { 237const BLOCK_SOURCE = '```js\nconst a = 1;\n```' ; 238const BLOCK_SELECTOR = 'code[data-code-token="code_block"]' ; 239 240function blockIn ( root :HTMLElement ) :HTMLElement { 241const el = root . querySelector ( BLOCK_SELECTOR ); 242 243if ( ! ( el instanceof HTMLElement )) throw new Error ( 'code block not rendered' ); 244 245return el ; 246} 247 248// Caret at the very start/end of the block's text (across highlight spans) 249function placeCaretInBlock ( root :HTMLElement , where :'start' | 'end' ) { 250const code = blockIn ( root ); 251const walker = document . createTreeWalker ( code , NodeFilter . SHOW_TEXT ); 252 253let target :Node | null = null ; 254 255for ( let n = walker . nextNode (); n ; n = walker . nextNode ()) { 256target = where === 'start' ?( target ?? n ) :n ; 257} 258 259if ( ! target ) throw new Error ( 'no text inside code block' ); 260 261setSelection ( root , ( range ) => { 262range . setStart ( target ! , where === 'start' ?0 :( target ! . textContent ?? '' ). length ); 263range . collapse ( true ); 264}); 265} 266 267function caretContainer () :Node { 268const selection = window . getSelection (); 269 270if ( ! selection || selection . rangeCount === 0 ) throw new Error ( 'no selection' ); 271 272return selection . getRangeAt ( 0 ). startContainer ; 273} 274 275it ( 'pads a trailing code block with a br hatch that stays invisible to copy' , async () => { 276const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 277 278await tick (); 279 280const root = editableIn ( container ); 281 282// no permanent empty line above a leading block 283expect ( root . firstChild ). toBe ( blockIn ( root )); 284expect ( root . lastChild ?. nodeName ). toBe ( 'BR' ); 285 286setSelection ( root , ( range ) => range . selectNodeContents ( root )); 287const { data, event} = clipboardEvent ( 'copy' ); 288 289root . dispatchEvent ( event ); 290 291expect ( data . getData ( 'text/plain' )). toBe ( BLOCK_SOURCE ); 292}); 293 294it ( 'escapes a trailing code block with ArrowDown and types after it' , async () => { 295const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 296 297await tick (); 298 299const root = editableIn ( container ); 300 301root . focus (); 302placeCaretInBlock ( root , 'end' ); 303 304await userEvent . keyboard ( '{ArrowDown}' ); 305expect ( blockIn ( root ). contains ( caretContainer ())). toBe ( false ); 306 307await userEvent . keyboard ( 'x' ); 308await tick (); 309 310expect ( blockIn ( root ). textContent ). toBe ( BLOCK_SOURCE ); 311// the DOM holds no separator newline (it would render as a 312// phantom empty line); serialization synthesizes it so the 313// markdown source keeps the text below the block 314expect ( root . textContent ). toBe ( BLOCK_SOURCE + 'x' ); 315expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\nx' ); 316// the stale trailing hatch is removed once real text follows the block 317expect ( root . lastChild ?. nodeName ). not . toBe ( 'BR' ); 318}); 319 320it ( 'escapes a leading code block with ArrowUp and types before it' , async () => { 321const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 322 323await tick (); 324 325const root = editableIn ( container ); 326 327root . focus (); 328placeCaretInBlock ( root , 'start' ); 329 330await userEvent . keyboard ( '{ArrowUp}' ); 331expect ( blockIn ( root ). contains ( caretContainer ())). toBe ( false ); 332// the transient hatch line exists while the caret sits on it 333expect ( root . firstChild ?. nodeName ). toBe ( 'BR' ); 334 335await userEvent . keyboard ( 'y' ); 336await tick (); 337 338expect ( blockIn ( root ). textContent ). toBe ( BLOCK_SOURCE ); 339expect ( root . textContent ). toBe ( 'y' + BLOCK_SOURCE ); 340expect ( serializeContent ( root )). toBe ( 'y\n' + BLOCK_SOURCE ); 341// the typed text consumed the hatch 342expect ( root . firstChild ?. nodeName ). not . toBe ( 'BR' ); 343}); 344 345it ( 'escapes a leading code block with ArrowLeft from its first character' , async () => { 346const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 347 348await tick (); 349 350const root = editableIn ( container ); 351 352root . focus (); 353placeCaretInBlock ( root , 'start' ); 354 355await userEvent . keyboard ( '{ArrowLeft}' ); 356expect ( blockIn ( root ). contains ( caretContainer ())). toBe ( false ); 357expect ( root . firstChild ?. nodeName ). toBe ( 'BR' ); 358}); 359 360it ( 'removes the transient leading hatch when the caret moves back into the block' , async () => { 361const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 362 363await tick (); 364 365const root = editableIn ( container ); 366 367root . focus (); 368placeCaretInBlock ( root , 'start' ); 369 370await userEvent . keyboard ( '{ArrowUp}' ); 371expect ( root . firstChild ?. nodeName ). toBe ( 'BR' ); 372 373await userEvent . keyboard ( '{ArrowDown}' ); 374await tick (); 375 376expect ( blockIn ( root ). contains ( caretContainer ())). toBe ( true ); 377expect ( root . firstChild ). toBe ( blockIn ( root )); 378}); 379 380it ( 'extends the selection out of the block with Shift+ArrowDown' , async () => { 381const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 382 383await tick (); 384 385const root = editableIn ( container ); 386 387root . focus (); 388placeCaretInBlock ( root , 'end' ); 389 390await userEvent . keyboard ( '{Shift>}{ArrowDown}{/Shift}' ); 391 392const selection = window . getSelection (); 393 394expect ( selection ). not . toBeNull (); 395expect ( selection ! . isCollapsed ). toBe ( false ); 396expect ( blockIn ( root ). contains ( selection ! . getRangeAt ( 0 ). endContainer )). toBe ( false ); 397}); 398 399it ( 'line-separates text typed right after the closing fence' , async () => { 400const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 401 402await tick (); 403 404const root = editableIn ( container ); 405 406root . focus (); 407placeCaretInBlock ( root , 'end' ); 408 409// no arrow keys: the caret sits at the block's end edge, where the 410// post-rebuild restore lands it, and the typed text renders on the 411// line below the block 412await userEvent . keyboard ( 'x' ); 413await tick (); 414 415// the text stays on the caret's line in the DOM (no phantom empty 416// line); the source gets the separator newline 417expect ( root . textContent ). toBe ( BLOCK_SOURCE + 'x' ); 418expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\nx' ); 419}); 420 421it ( 'does not double the newline when Shift+Enter already added one' , async () => { 422const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 423 424await tick (); 425 426const root = editableIn ( container ); 427 428root . focus (); 429placeCaretInBlock ( root , 'end' ); 430 431await userEvent . keyboard ( '{Shift>}{Enter}{/Shift}' ); 432await userEvent . keyboard ( 'x' ); 433await tick (); 434 435expect ( root . textContent ). toBe ( BLOCK_SOURCE + 'x' ); 436expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\nx' ); 437}); 438 439it ( 'moves a caret stuck before the inserted newline onto the new line' , async () => { 440const { container} = render ( ChatFormInputRich , { 441value :BLOCK_SOURCE + '\ntext after the code block' 442}); 443 444await tick (); 445 446const root = editableIn ( container ); 447 448root . focus (); 449 450// post-break DOM some browsers produce: the inserted newline plus 451// the artificial trailing one, with the caret stuck BEFORE the 452// inserted one (visually at the end of the old line) 453root . appendChild ( document . createTextNode ( '\n' )); 454root . appendChild ( document . createTextNode ( '\n' )); 455setSelection ( root , ( range ) => { 456range . setStart ( root . childNodes [ 2 ], 0 ); 457range . collapse ( true ); 458}); 459 460root . dispatchEvent ( new InputEvent ( 'input' , { bubbles :true , inputType :'insertLineBreak' })); 461await tick (); 462 463const selection = window . getSelection (); 464 465expect ( rangeToTextOffset ( root , selection ! . getRangeAt ( 0 ))). toBe ( 466( BLOCK_SOURCE + '\ntext after the code block\n' ). length 467); 468expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ntext after the code block\n\n' ); 469}); 470 471it ( 'appends the artificial trailing newline when the browser did not add one' , async () => { 472const { container} = render ( ChatFormInputRich , { 473value :BLOCK_SOURCE + '\ntext after the code block' 474}); 475 476await tick (); 477 478const root = editableIn ( container ); 479 480root . focus (); 481 482// post-break DOM some browsers produce: a lone trailing \n (or a 483// <br> the hatch sync strips). Collapsed by the renderer, so the 484// caret looks stuck on the old line and the next typed character 485// would consume the newline. 486root . appendChild ( document . createTextNode ( '\n' )); 487setSelection ( root , ( range ) => { 488range . setStart ( root . childNodes [ 2 ], 1 ); 489range . collapse ( true ); 490}); 491 492root . dispatchEvent ( new InputEvent ( 'input' , { bubbles :true , inputType :'insertLineBreak' })); 493await tick (); 494 495const selection = window . getSelection (); 496 497expect ( rangeToTextOffset ( root , selection ! . getRangeAt ( 0 ))). toBe ( 498( BLOCK_SOURCE + '\ntext after the code block\n' ). length 499); 500expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ntext after the code block\n\n' ); 501}); 502 503it ( 'lands the caret on the new line with a single Shift+Enter after text below a block' , async () => { 504const { container} = render ( ChatFormInputRich , { 505value :BLOCK_SOURCE + '\ntext after the code block' 506}); 507 508await tick (); 509 510const root = editableIn ( container ); 511 512root . focus (); 513setSelection ( root , ( range ) => { 514const text = root . childNodes [ 1 ]; 515 516range . setStart ( text , ( text . textContent ?? '' ). length ); 517range . collapse ( true ); 518}); 519 520await userEvent . keyboard ( '{Shift>}{Enter}{/Shift}' ); 521await tick (); 522 523const selection = window . getSelection (); 524 525expect ( rangeToTextOffset ( root , selection ! . getRangeAt ( 0 ))). toBe ( 526( BLOCK_SOURCE + '\ntext after the code block\n' ). length 527); 528expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ntext after the code block\n\n' ); 529 530// the next typed character lands on the new line 531await userEvent . keyboard ( 'x' ); 532await tick (); 533expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ntext after the code block\nx' ); 534}); 535 536it ( 'lets Backspace at the text start move into the block without a source fight' , async () => { 537const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 538 539await tick (); 540 541const root = editableIn ( container ); 542 543root . focus (); 544placeCaretInBlock ( root , 'end' ); 545 546await userEvent . keyboard ( '{ArrowDown}' ); 547await userEvent . keyboard ( 'create' ); 548await tick (); 549expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ncreate' ); 550 551// Backspace at the start of the text line: the separator newline 552// is structural (synthesized while text follows the block), so 553// the caret just moves to the block's edge - nothing is re-added 554await userEvent . keyboard ( '{Home}' ); 555await userEvent . keyboard ( '{Backspace}' ); 556await tick (); 557 558expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\ncreate' ); 559expect ( caretContainer ()). toBe ( root ); 560}); 561 562it ( 'lets forward Delete eat the text after a block normally' , async () => { 563const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 564 565await tick (); 566 567const root = editableIn ( container ); 568 569root . focus (); 570placeCaretInBlock ( root , 'end' ); 571 572await userEvent . keyboard ( '{ArrowDown}' ); 573await userEvent . keyboard ( 'create' ); 574await tick (); 575 576await userEvent . keyboard ( '{Home}' ); 577await userEvent . keyboard ( '{Delete}' ); 578await tick (); 579 580expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\nreate' ); 581}); 582 583it ( 'renders text after a block without a phantom empty line' , async () => { 584const { container} = render ( ChatFormInputRich , { 585value :BLOCK_SOURCE + '\nhello' 586}); 587 588await tick (); 589 590const root = editableIn ( container ); 591 592expect ( root . textContent ). toBe ( BLOCK_SOURCE + 'hello' ); 593expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\nhello' ); 594}); 595 596it ( 'keeps an intentional blank line after a block out of the separator' , async () => { 597const { container} = render ( ChatFormInputRich , { 598value :BLOCK_SOURCE + '\n\nhello' 599}); 600 601await tick (); 602 603const root = editableIn ( container ); 604 605expect ( root . textContent ). toBe ( BLOCK_SOURCE + '\nhello' ); 606expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE + '\n\nhello' ); 607}); 608 609it ( 're-highlights while typing inside a block and keeps the caret' , async () => { 610const { container} = render ( ChatFormInputRich , { value :BLOCK_SOURCE }); 611 612await tick (); 613 614const root = editableIn ( container ); 615 616root . focus (); 617 618// caret at the start of the block content (after the opening fence) 619setSelection ( root , ( range ) => { 620const target = textOffsetToRange ( root , 6 ); 621 622range . setStart ( target . startContainer , target . startOffset ); 623range . collapse ( true ); 624}); 625 626await userEvent . keyboard ( 'x' ); 627await tick (); 628 629const code = blockIn ( root ); 630 631expect ( serializeContent ( root )). toBe ( '```js\nxconst a = 1;\n```' ); 632expect ( code . textContent ). toBe ( '```js\nxconst a = 1;\n```' ); 633expect ( code . querySelector ( '.hljs-number' )). not . toBeNull (); 634 635const selection = window . getSelection (); 636 637expect ( code . contains ( selection ! . getRangeAt ( 0 ). startContainer )). toBe ( true ); 638expect ( rangeToTextOffset ( root , selection ! . getRangeAt ( 0 ))). toBe ( 7 ); 639}); 640}); 641 642describe ( 'ChatFormInputRich Enter in code blocks' , () => { 643const BLOCK_SOURCE = '```js\nconst a = 1;\n```' ; 644 645it ( 'adds a line instead of submitting on plain Enter inside a block' , async () => { 646const onKeydown = vi . fn (); 647const { container} = render ( ChatFormInputRich , { 648 onKeydown, 649value :BLOCK_SOURCE 650}); 651 652await tick (); 653 654const root = editableIn ( container ); 655 656root . focus (); 657 658// caret at the start of the block content (after the opening fence) 659setSelection ( root , ( range ) => { 660const target = textOffsetToRange ( root , 6 ); 661 662range . setStart ( target . startContainer , target . startOffset ); 663range . collapse ( true ); 664}); 665 666await userEvent . keyboard ( '{Enter}' ); 667await tick (); 668 669// consumed locally: the parent's submit handler never sees it 670expect ( onKeydown ). not . toHaveBeenCalled (); 671expect ( serializeContent ( root )). toBe ( '```js\n\nconst a = 1;\n```' ); 672 673const code = root . querySelector ( 'code[data-code-token="code_block"]' ); 674const selection = window . getSelection (); 675 676expect ( code ! . contains ( selection ! . getRangeAt ( 0 ). startContainer )). toBe ( true ); 677expect ( rangeToTextOffset ( root , selection ! . getRangeAt ( 0 ))). toBe ( 7 ); 678}); 679 680it ( 'adds a line after a still-open fence (no closing ``` yet)' , async () => { 681const onKeydown = vi . fn (); 682const { container} = render ( ChatFormInputRich , { 683 onKeydown, 684value :'```js\nconst a = 1;' 685}); 686 687await tick (); 688 689const root = editableIn ( container ); 690 691root . focus (); 692 693// caret at the start of the block content (after the opening fence) 694setSelection ( root , ( range ) => { 695const target = textOffsetToRange ( root , 6 ); 696 697range . setStart ( target . startContainer , target . startOffset ); 698range . collapse ( true ); 699}); 700 701await userEvent . keyboard ( '{Enter}' ); 702await tick (); 703 704expect ( onKeydown ). not . toHaveBeenCalled (); 705expect ( serializeContent ( root )). toBe ( '```js\n\nconst a = 1;' ); 706}); 707 708it ( 'forwards plain Enter to the parent when the caret is outside a block' , async () => { 709const onKeydown = vi . fn (); 710const { container} = render ( ChatFormInputRich , { 711 onKeydown, 712value :BLOCK_SOURCE + '\nafter' 713}); 714 715await tick (); 716 717const root = editableIn ( container ); 718 719root . focus (); 720setSelection ( root , ( range ) => { 721range . selectNodeContents ( root ); 722range . collapse ( false ); 723}); 724 725await userEvent . keyboard ( '{Enter}' ); 726 727expect ( onKeydown ). toHaveBeenCalledTimes ( 1 ); 728expect ( onKeydown . mock . calls [ 0 ][ 0 ]. defaultPrevented ). toBe ( false ); 729}); 730 731it ( 'forwards plain Enter on the trailing hatch line after a block' , async () => { 732const onKeydown = vi . fn (); 733const { container} = render ( ChatFormInputRich , { 734 onKeydown, 735value :BLOCK_SOURCE 736}); 737 738await tick (); 739 740const root = editableIn ( container ); 741 742root . focus (); 743// root-level caret between the block and its trailing br hatch 744setSelection ( root , ( range ) => { 745range . setStart ( root , 1 ); 746range . collapse ( true ); 747}); 748 749await userEvent . keyboard ( '{Enter}' ); 750 751expect ( onKeydown ). toHaveBeenCalledTimes ( 1 ); 752}); 753 754it ( 'forwards Ctrl+Enter inside a block so explicit submit survives' , async () => { 755const onKeydown = vi . fn (); 756const { container} = render ( ChatFormInputRich , { 757 onKeydown, 758value :BLOCK_SOURCE 759}); 760 761await tick (); 762 763const root = editableIn ( container ); 764 765root . focus (); 766setSelection ( root , ( range ) => { 767const target = textOffsetToRange ( root , 6 ); 768 769range . setStart ( target . startContainer , target . startOffset ); 770range . collapse ( true ); 771}); 772 773await userEvent . keyboard ( '{Control>}{Enter}{/Control}' ); 774 775expect ( onKeydown ). toHaveBeenCalledWith ( 776expect . objectContaining ({ ctrlKey :true , key :'Enter' }) 777); 778expect ( serializeContent ( root )). toBe ( BLOCK_SOURCE ); 779}); 780 781it ( 'forwards Enter inside an inline code span' , async () => { 782const onKeydown = vi . fn (); 783const { container} = render ( ChatFormInputRich , { 784 onKeydown, 785value :'run `npm test` now' 786}); 787 788await tick (); 789 790const root = editableIn ( container ); 791 792root . focus (); 793const code = root . querySelector ( 'code[data-code-token="code_inline"]' ) ! ; 794 795setSelection ( root , ( range ) => { 796range . setStart ( code . firstChild ! , 3 ); 797range . collapse ( true ); 798}); 799 800await userEvent . keyboard ( '{Enter}' ); 801 802expect ( onKeydown ). toHaveBeenCalledTimes ( 1 ); 803}); 804});