Преглед на файлове

test(ui-conversation): align suites with the editor composer

Drive keyboard gestures as real KeyboardEvents at the contenteditable
(Lexical routes them through the command layer), write drafts through the
shell, and probe decorations at their new DOM (chip decorators, styled
claim leaf, hint CSS variable, text-ref entity nodes). jsdom lacks
Selection.modify, so the Backspace-deletes-chip gesture moves to the
browser lane.
Yichen Jiang преди 1 месец
родител
ревизия
7222b066d5

+ 0 - 12
packages/client/ui-conversation/src/client/input/decorations.ts

@@ -22,18 +22,6 @@ export interface TextRefRange {
   readonly appearance?: 'folder'
 }
 
-/** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */
-export interface DraftDecorations {
-  /** Claim token range while claimed/submitting and the prefix watch holds; null otherwise. */
-  readonly token: TokenRange | null
-  /** Chip render instructions in draft order (occurrence table is offset-sorted). */
-  readonly chips: readonly ChipRender[]
-  /** Scan-derived lexicon tokens and syntax-recognizable folder ranges. */
-  readonly textRefs: readonly TextRefRange[]
-  /** Ghost hint shown while the claim's args are blank; null otherwise. */
-  readonly hint: string | null
-}
-
 /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
 const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
 const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g

+ 3 - 3
packages/client/ui-conversation/src/client/input/editor/keymap.ts

@@ -41,6 +41,7 @@ export interface ComposerKeymapHandlers {
 /** Composition state a keydown can trust (see the module doc's Safari note). */
 function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean): boolean {
   // keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
+  // oxlint-disable-next-line typescript/no-deprecated
   return event.isComposing || event.keyCode === 229 || recentlyComposing()
 }
 
@@ -87,9 +88,8 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
       // Escape layering: an open overlay closes; claimed without an overlay
       // does NOT release (backspacing the token is the only exit gesture).
       handlers.dismissPopup()
-      const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
-      if (handlers.arbitrate('escape', inComposition) === 'consumed') {
-        event?.preventDefault()
+      if (handlers.arbitrate('escape', isComposingEvent(event, recentlyComposing)) === 'consumed') {
+        event.preventDefault()
         return true
       }
       return false

+ 1 - 1
packages/client/ui-conversation/src/client/input/facade.ts

@@ -424,7 +424,7 @@ export class SessionInputShell implements SessionInput {
     // Leading-trigger contract: only whitespace may precede the span; the
     // whitespace prefix is dropped so the claimed watch (startsWith) holds.
     if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
-    let applied = false
+    let applied = false as boolean
     this.applyEdit(() => {
       applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token)
     })

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -335,7 +335,7 @@ export function InputBar({
   // token draft). The translated per-command hint wins over the claim's own.
   const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting')
     && input.claim !== undefined && draft.startsWith(input.claim.token)
-  const rawHint = claimActive && input.claim?.hint !== undefined
+  const rawHint = claimActive && input.claim.hint !== undefined
     && draft.slice(input.claim.token.length).trim() === ''
     ? input.claim.hint
     : null

+ 1 - 1
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -14,7 +14,7 @@ import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-c
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
   top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
-}) as DOMRect
+})
 
 
 usePinnedBrowserLanguages('zh-CN')

+ 1 - 1
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -25,7 +25,7 @@ import { zh } from '../src/client/locales.ts'
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
   top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
-}) as DOMRect
+})
 
 
 afterEach(cleanup)

+ 1 - 1
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -34,7 +34,7 @@ import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/clien
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
   top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
-}) as DOMRect
+})
 
 
 afterEach(cleanup)

+ 0 - 199
packages/client/ui-conversation/tests/lexical-spike.client.spec.tsx

@@ -1,199 +0,0 @@
-// @vitest-environment jsdom
-/**
- * SPIKE (Phase 0): validates the jsdom driving model for the Lexical composer
- * before the real implementation lands. Answers three questions: (1) can a
- * shell-owned headful editor render into jsdom and accept update-driven
- * edits; (2) can synthetic beforeinput/keyboard events drive Lexical in
- * jsdom, or must component tests drive through the command layer; (3) do
- * DecoratorNode portals render and keep DOM identity when text is inserted
- * before them. Deleted/absorbed into the real suites at the end of Phase 4.
- */
-import { describe, expect, it } from 'vitest'
-import * as React from 'react'
-import { createPortal } from 'react-dom'
-import { act, render } from '@testing-library/react'
-import type { EditorConfig, LexicalEditor, NodeKey, SerializedLexicalNode } from 'lexical'
-import {
-  $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
-  createEditor, DecoratorNode,
-} from 'lexical'
-import { registerPlainText } from '@lexical/plain-text'
-
-/** Minimal inline atomic chip for the spike (real node lands in Phase 1). */
-class SpikeChipNode extends DecoratorNode<React.JSX.Element> {
-  static getType(): string {
-    return 'spike-chip'
-  }
-
-  static clone(node: SpikeChipNode): SpikeChipNode {
-    return new SpikeChipNode(node.__key)
-  }
-
-  static importJSON(): SpikeChipNode {
-    return new SpikeChipNode()
-  }
-
-  exportJSON(): SerializedLexicalNode {
-    return { type: 'spike-chip', version: 1 }
-  }
-
-  createDOM(_config: EditorConfig): HTMLElement {
-    const el = document.createElement('span')
-    el.dataset['spikeChip'] = 'true'
-    return el
-  }
-
-  updateDOM(): boolean {
-    return false
-  }
-
-  isInline(): boolean {
-    return true
-  }
-
-  getTextContent(): string {
-    return '/chip-clipboard'
-  }
-
-  decorate(): React.JSX.Element {
-    return <button type="button" data-chip-button>chip-label</button>
-  }
-}
-
-function makeEditor(): { editor: LexicalEditor; rootEl: HTMLDivElement } {
-  const editor = createEditor({
-    namespace: 'spike',
-    nodes: [SpikeChipNode],
-    onError: (error) => { throw error },
-  })
-  const rootEl = document.createElement('div')
-  rootEl.contentEditable = 'true'
-  document.body.appendChild(rootEl)
-  editor.setRootElement(rootEl)
-  registerPlainText(editor)
-  return { editor, rootEl }
-}
-
-describe('lexical jsdom spike', () => {
-  it('renders update-driven text into the DOM and reports projections', async () => {
-    const { editor, rootEl } = makeEditor()
-    editor.update(() => {
-      const p = $createParagraphNode()
-      p.append($createTextNode('hello world'))
-      $getRoot().append(p)
-    }, { discrete: true })
-    await Promise.resolve() // reconciliation is sync inside update; flush microtasks anyway
-    expect(rootEl.textContent).toBe('hello world')
-    const text = editor.getEditorState().read(() => $getRoot().getTextContent())
-    expect(text).toBe('hello world')
-  })
-
-  it('answers whether synthetic beforeinput insertText drives Lexical under jsdom', () => {
-    const { editor, rootEl } = makeEditor()
-    editor.update(() => {
-      const p = $createParagraphNode()
-      p.append($createTextNode('ab'))
-      $getRoot().append(p)
-    }, { discrete: true })
-    // Place a real DOM selection at the end of the text node.
-    const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT)
-    const textDom = walker.nextNode()
-    const selectable = textDom !== null && textDom !== undefined
-    if (selectable) {
-      const sel = document.getSelection()
-      const range = document.createRange()
-      range.setStart(textDom, 2)
-      range.collapse(true)
-      sel?.removeAllRanges()
-      sel?.addRange(range)
-      document.dispatchEvent(new Event('selectionchange'))
-    }
-    const event = new InputEvent('beforeinput', {
-      inputType: 'insertText', data: 'X', bubbles: true, cancelable: true,
-    })
-    rootEl.dispatchEvent(event)
-    const after = editor.getEditorState().read(() => $getRoot().getTextContent())
-    // Record the verdict either way — the spike's job is the answer, not a pass.
-    console.log(`SPIKE beforeinput verdict: selectable=${selectable} after=${JSON.stringify(after)}`)
-    expect(typeof after).toBe('string')
-  })
-
-  it('keeps decorator DOM identity when text is inserted before the chip', async () => {
-    const { editor, rootEl } = makeEditor()
-    // Decorator portal loop (what the real DecoratorPortals component will do).
-    function Portals(): React.JSX.Element {
-      const [decorators, setDecorators] = React.useState<Record<NodeKey, React.JSX.Element>>(
-        () => editor.getDecorators<React.JSX.Element>(),
-      )
-      React.useLayoutEffect(
-        () => editor.registerDecoratorListener<React.JSX.Element>((next) => { setDecorators(next) }),
-        [],
-      )
-      return (
-        <>
-          {Object.entries(decorators).map(([key, jsx]) => {
-            const el = editor.getElementByKey(key)
-            return el === null ? null : createPortal(jsx, el, key)
-          })}
-        </>
-      )
-    }
-    render(<Portals />)
-
-    let chipKey = ''
-    act(() => {
-      editor.update(() => {
-        const p = $createParagraphNode()
-        const chip = new SpikeChipNode()
-        chipKey = chip.getKey()
-        p.append($createTextNode('before '), chip, $createTextNode(' after'))
-        $getRoot().append(p)
-      }, { discrete: true })
-    })
-    await Promise.resolve()
-    const chipButton = rootEl.querySelector('[data-chip-button]')
-    expect(chipButton).not.toBeNull()
-    const chipSpan = rootEl.querySelector('[data-spike-chip]')
-
-    // Insert text before the chip through the node API (the transaction path).
-    act(() => {
-      editor.update(() => {
-        const p = $getRoot().getFirstChild()
-        if (p === null) throw new Error('paragraph missing')
-        const first = (p as ReturnType<typeof $createParagraphNode>).getFirstChild()
-        if (first === null) throw new Error('text missing')
-        ;(first as ReturnType<typeof $createTextNode>).spliceText(0, 0, '@')
-      }, { discrete: true })
-    })
-    await Promise.resolve()
-    // Chip DOM node identity survives (bug #2793's structural fix).
-    expect(rootEl.querySelector('[data-spike-chip]')).toBe(chipSpan)
-    expect(rootEl.querySelector('[data-chip-button]')).toBe(chipButton)
-    // Chip node identity survives in the tree (bug #2813's structural fix).
-    const stillThere = editor.getEditorState().read(() => {
-      const node = editor.getEditorState()._nodeMap.get(chipKey)
-      return node !== undefined
-    })
-    expect(stillThere).toBe(true)
-    // Text content projection sees the clipboard form.
-    const text = editor.getEditorState().read(() => $getRoot().getTextContent())
-    expect(text).toBe('@before /chip-clipboard after')
-  })
-
-  it('reports whether selection APIs let update-driven caret placement work', () => {
-    const { editor } = makeEditor()
-    editor.update(() => {
-      const p = $createParagraphNode()
-      const t = $createTextNode('abc')
-      p.append(t)
-      $getRoot().append(p)
-      t.select(1, 1)
-    }, { discrete: true })
-    const verdict = editor.getEditorState().read(() => {
-      const sel = $getSelection()
-      return $isRangeSelection(sel) ? `range@${sel.anchor.offset}` : String(sel)
-    })
-    console.log(`SPIKE selection verdict: ${verdict}`)
-    expect(verdict).toBe('range@1')
-  })
-})

+ 1 - 1
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -34,7 +34,7 @@ import type { ViewTab } from '../src/client/contract/views.ts'
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
   top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
-}) as DOMRect
+})
 
 
 /** Machine-backed wiring over a sink spy. */