input-bar.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. // @vitest-environment jsdom
  2. // InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
  3. // shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
  4. // semantics (input stays free; primary turns stop), the machine pending lock,
  5. // decoration backdrop, error/notice strips, and the focus-keeping mousedown.
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  8. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  9. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  10. import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  11. import { SessionInputShell } from '../src/client/input/facade.ts'
  12. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  13. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  14. afterEach(cleanup)
  15. const SCTX = {} as ClientContext
  16. const SID = 's1' as SessionId
  17. function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
  18. return {
  19. sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
  20. pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
  21. openState: 'open', openError: null, hasMore: false, loadingOlder: false,
  22. promptError: null, blank: false, lastAgentError: null,
  23. ...overrides,
  24. }
  25. }
  26. interface BenchOptions {
  27. planEntry?: React.ReactNode
  28. modelEntry?: React.ReactNode
  29. /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
  30. lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
  31. draft?: string
  32. running?: boolean
  33. disabled?: boolean
  34. promptError?: ConversationSnapshot['promptError']
  35. variant?: 'hero' | 'composer'
  36. placeholder?: string
  37. accessory?: React.ReactNode
  38. overlay?: React.ReactNode
  39. leftItems?: React.ReactNode
  40. rightItems?: React.ReactNode
  41. }
  42. /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
  43. function bench(over?: BenchOptions) {
  44. const sink = vi.fn()
  45. const lex = over?.lexicon
  46. type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
  47. const shell = new SessionInputShell({
  48. actx: SCTX,
  49. defaultSink: sink,
  50. // Lexicon-only stub: adjudication untouched (undefined slash methods are
  51. // never reached — these benches drive plain-draft flows only).
  52. ...(lex !== undefined
  53. ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
  54. : {}),
  55. })
  56. if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
  57. const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
  58. running: over?.running ?? false,
  59. removed: over?.disabled ?? false,
  60. promptError: over?.promptError ?? null,
  61. }))
  62. const stop = vi.fn()
  63. const slotCalls: { key: string; owner: unknown }[] = []
  64. const renderSlot = ((key: string, owner: object) => {
  65. slotCalls.push({ key, owner })
  66. if (key === 'conversation.input.plan') return over?.planEntry ?? null
  67. if (key === 'conversation.input.model') return over?.modelEntry ?? null
  68. return null
  69. }) as InputBarProps['renderSlot']
  70. const props: InputBarProps = {
  71. sessionId: SID,
  72. SessionProvider: ({ children }) => children(SID),
  73. useSession: bindSnapshotSelector(session),
  74. useSessions: bindSnapshotSelector(createSnapshotStore({
  75. ids: [], byId: {}, current: undefined, phase: 'ready',
  76. })) as InputBarProps['useSessions'],
  77. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  78. items: [], state: 'idle', phase: 'ready', error: null,
  79. baselinesReady: true, recentWorkspaceId: undefined,
  80. })) as InputBarProps['useWorkspaces'],
  81. useInput: bindSnapshotSelector(shell.state),
  82. inputActions: shell.actions,
  83. keyboard: shell,
  84. stop,
  85. renderSlot,
  86. variant: over?.variant ?? 'composer',
  87. ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
  88. ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
  89. ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
  90. ...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
  91. ...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
  92. }
  93. const view = render(<InputBar {...props} />)
  94. const textarea = view.container.querySelector('textarea')!
  95. // aria-label (not role name): title carries the same label and would double-match.
  96. const button = view.container.querySelector<HTMLButtonElement>(
  97. `button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
  98. )!
  99. return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
  100. }
  101. describe('Enter semantics', () => {
  102. it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
  103. const { textarea, sink } = bench({ draft: 'hello' })
  104. fireEvent.keyDown(textarea, { key: 'Enter' })
  105. expect(sink).toHaveBeenCalledWith('hello', 'queue')
  106. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
  107. expect(sink).toHaveBeenCalledTimes(1)
  108. const empty = bench({ draft: ' ' })
  109. fireEvent.keyDown(empty.textarea, { key: 'Enter' })
  110. expect(empty.sink).not.toHaveBeenCalled()
  111. })
  112. it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
  113. const { textarea, sink } = bench({ draft: 'hello' })
  114. fireEvent.keyDown(textarea, { key: 'a' })
  115. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  116. expect(sink).not.toHaveBeenCalled()
  117. })
  118. it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
  119. const { textarea, sink } = bench({ draft: 'hello' })
  120. fireEvent.compositionStart(textarea)
  121. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  122. expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
  123. })
  124. it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
  125. const { textarea, shell, sink } = bench({ draft: 'hello' })
  126. textarea.setSelectionRange(5, 5)
  127. fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
  128. expect(shell.snapshot.draft).toBe('hello\n')
  129. expect(sink).not.toHaveBeenCalled()
  130. })
  131. it('platform undo/redo chords route to the machine, never the browser stack', () => {
  132. const { textarea, shell } = bench({ draft: '' })
  133. fireEvent.change(textarea, { target: { value: 'first' } })
  134. fireEvent.change(textarea, { target: { value: 'first second' } })
  135. fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
  136. expect(shell.snapshot.draft).not.toBe('first second')
  137. fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
  138. expect(shell.snapshot.draft).toBe('first second')
  139. })
  140. it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
  141. vi.useFakeTimers()
  142. try {
  143. const { textarea, sink } = bench({ draft: 'hello' })
  144. fireEvent.compositionStart(textarea)
  145. fireEvent.keyDown(textarea, { key: 'Enter' })
  146. expect(sink).not.toHaveBeenCalled()
  147. fireEvent.compositionEnd(textarea)
  148. // Safari delivers the closing keydown before the deferred clear.
  149. fireEvent.keyDown(textarea, { key: 'Enter' })
  150. expect(sink).not.toHaveBeenCalled()
  151. vi.advanceTimersByTime(20)
  152. fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
  153. expect(sink).not.toHaveBeenCalled()
  154. fireEvent.keyDown(textarea, { key: 'Enter' })
  155. expect(sink).toHaveBeenCalledTimes(1)
  156. } finally {
  157. vi.useRealTimers()
  158. }
  159. })
  160. })
  161. describe('running and lock semantics (queue cut 1)', () => {
  162. it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
  163. const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
  164. expect(textarea.disabled).toBe(false) // running no longer locks
  165. fireEvent.change(textarea, { target: { value: '排队消息2' } })
  166. fireEvent.keyDown(textarea, { key: 'Enter' })
  167. expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
  168. expect(button.getAttribute('aria-label')).toBe('Stop generating')
  169. fireEvent.click(button)
  170. expect(stop).toHaveBeenCalledTimes(1)
  171. })
  172. it('disabled (session removed) locks the textarea and chrome', () => {
  173. const { textarea, view } = bench({ disabled: true })
  174. expect(textarea.disabled).toBe(true)
  175. expect(textarea.placeholder).toBe('Session unavailable')
  176. expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
  177. })
  178. it('idle primary sends and disables on empty draft', () => {
  179. const { button, sink } = bench({ draft: 'go' })
  180. fireEvent.click(button)
  181. expect(sink).toHaveBeenCalledWith('go', 'queue')
  182. const empty = bench()
  183. expect(empty.button.disabled).toBe(true)
  184. })
  185. it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
  186. const first = bench({ disabled: true, draft: 'x' })
  187. act(() => { first.session.set(snapshotOf({ removed: false })) })
  188. const textarea = first.view.container.querySelector('textarea')!
  189. expect(document.activeElement).toBe(textarea)
  190. textarea.blur()
  191. fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
  192. expect(document.activeElement).toBe(textarea)
  193. })
  194. it('typing forwards through the machine (draft state echoes back)', () => {
  195. const { textarea, wiring } = bench()
  196. fireEvent.change(textarea, { target: { value: 'typed' } })
  197. expect(wiring.state.getSnapshot().draft).toBe('typed')
  198. expect((textarea as HTMLTextAreaElement).value).toBe('typed')
  199. })
  200. it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
  201. const { textarea } = bench({ disabled: true })
  202. expect(textarea.placeholder).toBe('Session unavailable')
  203. const live = bench()
  204. expect(live.textarea.placeholder).toBe('Message the agent')
  205. const custom = bench({ placeholder: 'Custom placeholder' })
  206. expect(custom.textarea.placeholder).toBe('Custom placeholder')
  207. })
  208. })
  209. describe('machine pending lock', () => {
  210. it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
  211. const { view, shell } = bench()
  212. // Drive the machine into submitting through a claim + enter.
  213. act(() => {
  214. shell.setDraft('/goal ')
  215. shell.beginCommand(
  216. {
  217. token: '/goal ',
  218. submit: () => new Promise<never>(() => {}), // never settles: stays submitting
  219. },
  220. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  221. )
  222. shell.submit('queue')
  223. })
  224. expect(shell.snapshot.phase).toBe('submitting')
  225. const textarea = view.container.querySelector('textarea')!
  226. expect(textarea.readOnly).toBe(true)
  227. expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
  228. expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
  229. })
  230. })
  231. describe('decorations', () => {
  232. it('claimed token renders the mirror highlight and the blank-args hint', () => {
  233. const { view, shell } = bench()
  234. act(() => {
  235. shell.setDraft('/goal ')
  236. shell.beginCommand(
  237. { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
  238. { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
  239. )
  240. })
  241. const token = view.container.querySelector('[data-decoration="token"]')
  242. expect(token?.textContent).toBe('/goal ')
  243. expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
  244. // Args typed: the hint disappears, the token highlight stays.
  245. act(() => { shell.setDraft('/goal 发布') })
  246. expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
  247. expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
  248. })
  249. it('an inserted reference renders as a chip at its placeholder offset', () => {
  250. const { view, shell } = bench()
  251. act(() => {
  252. shell.setDraft('参考 @w1 内容')
  253. shell.insertReference(
  254. { source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
  255. { start: 3, end: 6, draftRev: shell.snapshot.draftRev },
  256. )
  257. })
  258. const chip = view.container.querySelector('[data-decoration="chip"]')
  259. expect(chip?.textContent).toBe('@w1')
  260. expect(shell.snapshot.occurrences).toHaveLength(1)
  261. // The draft carries exactly one placeholder char where the token was.
  262. expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
  263. })
  264. it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
  265. const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
  266. const { view, shell } = bench({ lexicon })
  267. act(() => { shell.setDraft('use /fixture-demo now') })
  268. const mark = view.container.querySelector('[data-decoration="text-ref"]')
  269. expect(mark?.textContent).toBe('/fixture-demo')
  270. // Editing the token out of match shape drops the decoration.
  271. act(() => { shell.setDraft('use /fixture-dem now') })
  272. expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
  273. })
  274. })
  275. describe('insertText (decision 21 scoped event body)', () => {
  276. it('splices plain text over the span and reports success as true', () => {
  277. const { shell } = bench({ draft: '/fix' })
  278. const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
  279. expect(ok).toBe(true)
  280. expect(shell.snapshot.draft).toBe('/fixture-demo ')
  281. expect(shell.snapshot.occurrences).toEqual([])
  282. })
  283. it('a stale draftRev refuses whole: false, draft untouched', () => {
  284. const { shell } = bench({ draft: '/fix' })
  285. const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
  286. act(() => { shell.setDraft('/fixX') })
  287. expect(shell.insertText('/fixture-demo ', span)).toBe(false)
  288. expect(shell.snapshot.draft).toBe('/fixX')
  289. })
  290. })
  291. describe('strips and variants', () => {
  292. it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
  293. const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
  294. expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
  295. expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
  296. })
  297. it('renders the notice strip from the machine notice store', () => {
  298. const { view, shell } = bench()
  299. act(() => { shell.notify('error', '命令失败了') })
  300. expect(view.getByText('命令失败了')).toBeTruthy()
  301. })
  302. it('hero variant adds the hero class and accessory row renders', () => {
  303. const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
  304. expect(view.getByTestId('acc')).toBeTruthy()
  305. expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
  306. })
  307. it('renders overlay anchor and left/right slot items', () => {
  308. const { view } = bench({
  309. overlay: <i data-testid="ov" />,
  310. leftItems: <i data-testid="li" />,
  311. rightItems: <i data-testid="ri" />,
  312. })
  313. expect(view.getByTestId('ov')).toBeTruthy()
  314. expect(view.getByTestId('li')).toBeTruthy()
  315. expect(view.getByTestId('ri')).toBeTruthy()
  316. })
  317. })
  318. describe('placeholder chrome and control seats', () => {
  319. it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
  320. const { view, slotCalls } = bench()
  321. expect(view.getByLabelText('Add attachment')).toBeTruthy()
  322. expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
  323. // Both seats dispatched, nothing rendered.
  324. expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
  325. expect(view.queryByLabelText('Plan mode')).toBeNull()
  326. expect(view.queryByLabelText('Model')).toBeNull()
  327. })
  328. it('a registered entry fills its seat and receives the locked owner prop', () => {
  329. const { view, slotCalls } = bench({
  330. disabled: true,
  331. planEntry: <i data-testid="plan-entry" />,
  332. modelEntry: <i data-testid="model-entry" />,
  333. })
  334. expect(view.getByTestId('plan-entry')).toBeTruthy()
  335. expect(view.getByTestId('model-entry')).toBeTruthy()
  336. // The bar hands its chrome disable state to the filling entry.
  337. expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
  338. cleanup()
  339. const live = bench({ running: true })
  340. expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
  341. })
  342. it('disabled locks the Access placeholder and attach control (running does not)', () => {
  343. const { view } = bench({ disabled: true })
  344. expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
  345. expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
  346. cleanup()
  347. const live = bench({ running: true })
  348. expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
  349. })
  350. })