input-bar.spec.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. // @vitest-environment jsdom
  2. // InputBar behavior: Enter-send semantics (IME guard, shift newline,
  3. // ctrl/meta insert, repeat suppression), the running lock with stop-only
  4. // action, unlock refocus, error strip copy, and the focus-keeping mousedown.
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { cleanup, fireEvent, render } from '@testing-library/react'
  7. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  8. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  9. afterEach(cleanup)
  10. function setup(over?: Partial<InputBarProps>) {
  11. const props: InputBarProps = {
  12. draft: 'hello', running: false, disabled: false, error: null,
  13. variant: 'composer',
  14. onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
  15. ...over,
  16. }
  17. const view = render(<InputBar {...props} />)
  18. const textarea = view.container.querySelector('textarea')!
  19. // aria-label (not role name): title also contains 发送/停止 and would double-match.
  20. const button = view.container.querySelector<HTMLButtonElement>(
  21. `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
  22. )!
  23. return { view, textarea, button, props }
  24. }
  25. describe('Enter semantics', () => {
  26. it('plain Enter sends queue mode; repeat and empty are suppressed', () => {
  27. const { textarea, props } = setup()
  28. fireEvent.keyDown(textarea, { key: 'Enter' })
  29. expect(props.onSend).toHaveBeenCalledWith('queue')
  30. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
  31. expect(props.onSend).toHaveBeenCalledTimes(1)
  32. const empty = setup({ draft: ' ' })
  33. fireEvent.keyDown(empty.textarea, { key: 'Enter' })
  34. expect(empty.props.onSend).not.toHaveBeenCalled()
  35. })
  36. it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
  37. const { textarea, props } = setup()
  38. fireEvent.keyDown(textarea, { key: 'a' })
  39. fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
  40. expect(props.onSend).not.toHaveBeenCalled()
  41. })
  42. it('Ctrl/Meta+Enter inserts a newline through execCommand instead of sending', () => {
  43. const exec = vi.fn()
  44. ;(document as unknown as { execCommand: typeof exec }).execCommand = exec
  45. const { textarea, props } = setup()
  46. fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
  47. expect(exec).toHaveBeenCalledWith('insertText', false, '\n')
  48. expect(props.onSend).not.toHaveBeenCalled()
  49. })
  50. it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', async () => {
  51. vi.useFakeTimers()
  52. try {
  53. const { textarea, props } = setup()
  54. fireEvent.compositionStart(textarea)
  55. fireEvent.keyDown(textarea, { key: 'Enter' })
  56. expect(props.onSend).not.toHaveBeenCalled()
  57. fireEvent.compositionEnd(textarea)
  58. // Safari delivers the closing keydown before the deferred clear.
  59. fireEvent.keyDown(textarea, { key: 'Enter' })
  60. expect(props.onSend).not.toHaveBeenCalled()
  61. vi.advanceTimersByTime(20)
  62. fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
  63. expect(props.onSend).not.toHaveBeenCalled()
  64. fireEvent.keyDown(textarea, { key: 'Enter' })
  65. expect(props.onSend).toHaveBeenCalledTimes(1)
  66. } finally {
  67. vi.useRealTimers()
  68. }
  69. })
  70. })
  71. describe('running lock and primary button', () => {
  72. it('running locks the textarea and turns the primary into stop', () => {
  73. const { textarea, button, props } = setup({ running: true })
  74. expect(textarea.disabled).toBe(true)
  75. expect(button.getAttribute('aria-label')).toBe('停止')
  76. fireEvent.click(button)
  77. expect(props.onStop).toHaveBeenCalledTimes(1)
  78. expect(props.onSend).not.toHaveBeenCalled()
  79. })
  80. it('idle primary sends and disables on empty draft', () => {
  81. const { button, props } = setup()
  82. fireEvent.click(button)
  83. expect(props.onSend).toHaveBeenCalledWith('queue')
  84. const empty = setup({ draft: '' })
  85. expect(empty.button.disabled).toBe(true)
  86. })
  87. it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
  88. const { view, props } = setup({ running: true })
  89. view.rerender(<InputBar {...props} running={false} />)
  90. const textarea = view.container.querySelector('textarea')!
  91. expect(document.activeElement).toBe(textarea)
  92. textarea.blur()
  93. fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
  94. expect(document.activeElement).toBe(textarea)
  95. })
  96. it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
  97. const { textarea } = setup({ disabled: true, draft: '' })
  98. expect(textarea.placeholder).toBe('会话不可用')
  99. const live = setup({ draft: '' })
  100. expect(live.textarea.placeholder).toContain('Enter 发送')
  101. fireEvent.change(live.textarea, { target: { value: 'typed' } })
  102. expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
  103. const runningPh = setup({ running: true, draft: '' })
  104. expect(runningPh.textarea.placeholder).toContain('停止')
  105. const custom = setup({ placeholder: '自定义' })
  106. expect(custom.textarea.placeholder).toBe('自定义')
  107. })
  108. })
  109. describe('error strip and variants', () => {
  110. it('renders send and stop failure copy', () => {
  111. const send = setup({ error: { op: 'send', message: 'boom' } })
  112. expect(send.view.getByText(/发送失败:boom/)).toBeTruthy()
  113. const stop = setup({ error: { op: 'stop', message: 'halt' } })
  114. expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy()
  115. })
  116. it('hero variant adds the hero class and accessory row renders', () => {
  117. const { view } = setup({ variant: 'hero', accessory: <i data-testid="acc" /> })
  118. expect(view.getByTestId('acc')).toBeTruthy()
  119. expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
  120. })
  121. })
  122. describe('placeholder chrome', () => {
  123. it('renders attach / Plan / Read-only / model controls', () => {
  124. const { view } = setup()
  125. expect(view.getByLabelText('添加')).toBeTruthy()
  126. expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
  127. expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
  128. expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
  129. })
  130. it('native select change updates the selected option', () => {
  131. const { view } = setup()
  132. const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
  133. fireEvent.change(plan, { target: { value: 'agent' } })
  134. expect(plan.value).toBe('agent')
  135. const access = view.getByLabelText('Access mode') as HTMLSelectElement
  136. fireEvent.change(access, { target: { value: 'readwrite' } })
  137. expect(access.value).toBe('readwrite')
  138. })
  139. it('model select can drop the High option', () => {
  140. const { view } = setup()
  141. const model = view.getByLabelText('Model') as HTMLSelectElement
  142. fireEvent.change(model, { target: { value: 'v4-pro' } })
  143. expect(model.value).toBe('v4-pro')
  144. expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
  145. })
  146. it('running locks the chrome selects and attach control', () => {
  147. const { view } = setup({ running: true })
  148. expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
  149. expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
  150. expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
  151. })
  152. })