atoms.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
  5. afterEach(cleanup)
  6. describe('Button', () => {
  7. it('renders children, icon, and forwards clicks', () => {
  8. const onClick = vi.fn()
  9. render(<Button variant="primary" icon={<svg data-testid="ic" />} onClick={onClick}>Go</Button>)
  10. const button = screen.getByRole('button', { name: 'Go' })
  11. expect(screen.getByTestId('ic')).toBeDefined()
  12. fireEvent.click(button)
  13. expect(onClick).toHaveBeenCalledTimes(1)
  14. })
  15. it('disabled blocks interaction', () => {
  16. const onClick = vi.fn()
  17. render(<Button disabled onClick={onClick}>No</Button>)
  18. fireEvent.click(screen.getByRole('button'))
  19. expect(onClick).not.toHaveBeenCalled()
  20. })
  21. it('outline variant renders a bordered cancel-style button', () => {
  22. render(<Button variant="outline">Cancel</Button>)
  23. expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
  24. })
  25. })
  26. describe('Pill', () => {
  27. it('is a span when static, a button when clickable', () => {
  28. const { rerender } = render(<Pill active>tab</Pill>)
  29. expect(screen.queryByRole('button')).toBeNull()
  30. rerender(<Pill onClick={() => {}}>tab</Pill>)
  31. expect(screen.getByRole('button', { name: 'tab' })).toBeDefined()
  32. })
  33. it('active and className land on both static and interactive forms', () => {
  34. const { container, rerender } = render(<Pill className="x">tab</Pill>)
  35. const asSpan = container.firstElementChild as HTMLElement
  36. expect(asSpan.classList.contains('x')).toBe(true)
  37. rerender(<Pill active className="x" onClick={() => {}}>tab</Pill>)
  38. const asButton = screen.getByRole('button')
  39. expect(asButton.classList.contains('x')).toBe(true)
  40. })
  41. })
  42. describe('Input', () => {
  43. it('forwards value/onChange and renders the leading icon', () => {
  44. const onChange = vi.fn()
  45. render(<Input icon={<svg data-testid="ic" />} value="q" onChange={onChange} placeholder="search" />)
  46. const input = screen.getByPlaceholderText<HTMLInputElement>('search')
  47. expect(input.value).toBe('q')
  48. fireEvent.change(input, { target: { value: 'qq' } })
  49. expect(onChange).toHaveBeenCalled()
  50. expect(screen.getByTestId('ic')).toBeDefined()
  51. })
  52. })
  53. describe('Menu', () => {
  54. const items = [
  55. { id: 'a', label: 'Alpha' },
  56. { id: 'b', label: 'Beta', disabled: true },
  57. ]
  58. it('shows items only while open; select fires onSelect', () => {
  59. const onSelect = vi.fn()
  60. const { rerender } = render(
  61. <Menu open={false} anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={() => {}} />)
  62. expect(screen.queryByRole('menu')).toBeNull()
  63. rerender(
  64. <Menu open anchor={<span>trigger</span>} items={items} selectedId="a" onSelect={onSelect} onClose={() => {}} />)
  65. fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
  66. expect(onSelect).toHaveBeenCalledWith('a')
  67. })
  68. it('disabled item does not select; Escape and outside pointerdown close', () => {
  69. const onSelect = vi.fn()
  70. const onClose = vi.fn()
  71. render(
  72. <Menu open anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={onClose} />)
  73. fireEvent.click(screen.getByRole('menuitem', { name: 'Beta' }))
  74. expect(onSelect).not.toHaveBeenCalled()
  75. fireEvent.keyDown(document, { key: 'Escape' })
  76. expect(onClose).toHaveBeenCalledTimes(1)
  77. fireEvent.pointerDown(document.body)
  78. expect(onClose).toHaveBeenCalledTimes(2)
  79. })
  80. it('inside pointerdown does not close', () => {
  81. const onClose = vi.fn()
  82. render(
  83. <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
  84. fireEvent.pointerDown(screen.getByRole('menuitem', { name: 'Alpha' }))
  85. expect(onClose).not.toHaveBeenCalled()
  86. })
  87. it('selected item shows the trailing check; align=end, side=top, and className apply', () => {
  88. const { container } = render(
  89. <Menu
  90. open
  91. align="end"
  92. side="top"
  93. className="x"
  94. anchor={<span>trigger</span>}
  95. items={items}
  96. selectedId="a"
  97. onSelect={() => {}}
  98. onClose={() => {}}
  99. />)
  100. expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true)
  101. const menu = screen.getByRole('menu')
  102. expect(menu.className).toMatch(/sideTop|alignEnd/)
  103. const selected = screen.getByRole('menuitem', { name: 'Alpha' })
  104. expect(selected.querySelector('svg')).not.toBeNull()
  105. const other = screen.getByRole('menuitem', { name: 'Beta' })
  106. expect(other.querySelector('svg')).toBeNull()
  107. fireEvent.keyDown(document, { key: 'a' })
  108. })
  109. it('renders a leading icon and a separator between groups', () => {
  110. render(
  111. <Menu
  112. open
  113. anchor={<span>trigger</span>}
  114. items={[
  115. { id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
  116. { type: 'separator', id: 's1' },
  117. { id: 'c', label: 'Create' },
  118. ]}
  119. onSelect={() => {}}
  120. onClose={() => {}}
  121. />)
  122. expect(screen.getByTestId('ic')).toBeDefined()
  123. expect(screen.getByRole('separator')).toBeDefined()
  124. })
  125. it('renders a non-interactive heading label and a danger row', () => {
  126. const onSelect = vi.fn()
  127. render(
  128. <Menu
  129. open
  130. anchor={<span>trigger</span>}
  131. items={[
  132. { type: 'label', id: 'h', text: 'Group by' },
  133. { id: 'del', label: 'Delete', danger: true },
  134. ]}
  135. onSelect={onSelect}
  136. onClose={() => {}}
  137. />)
  138. const heading = screen.getByText('Group by')
  139. expect(heading.getAttribute('role')).toBe('presentation')
  140. // The heading is not a menu item — only the danger row is interactive.
  141. expect(screen.getAllByRole('menuitem')).toHaveLength(1)
  142. const danger = screen.getByRole('menuitem', { name: 'Delete' })
  143. expect(danger.className).toMatch(/danger/)
  144. fireEvent.click(danger)
  145. expect(onSelect).toHaveBeenCalledWith('del')
  146. })
  147. it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
  148. const onClose = vi.fn()
  149. const { rerender } = render(
  150. <Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
  151. fireEvent.pointerLeave(screen.getByRole('menu'))
  152. expect(onClose).toHaveBeenCalledTimes(1)
  153. rerender(
  154. <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
  155. fireEvent.pointerLeave(screen.getByRole('menu'))
  156. expect(onClose).toHaveBeenCalledTimes(1)
  157. })
  158. it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
  159. const rowClick = vi.fn()
  160. render(
  161. <div onClick={rowClick}>
  162. <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />
  163. </div>)
  164. fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
  165. expect(rowClick).not.toHaveBeenCalled()
  166. })
  167. it('opens a submenu on hover and selects a nested item', () => {
  168. const onSelect = vi.fn()
  169. render(
  170. <Menu
  171. open
  172. anchor={<span>trigger</span>}
  173. items={[
  174. { id: 'plain', label: 'Plain' },
  175. {
  176. id: 'new',
  177. label: 'New Workspace',
  178. submenu: [
  179. { id: 'ok', label: 'Create ok', icon: <svg data-testid="sub-ic" /> },
  180. ],
  181. },
  182. ]}
  183. onSelect={onSelect}
  184. onClose={() => {}}
  185. />)
  186. const plain = screen.getByRole('menuitem', { name: 'Plain' })
  187. fireEvent.mouseEnter(plain.parentElement as HTMLElement)
  188. fireEvent.focus(plain)
  189. const parent = screen.getByRole('menuitem', { name: 'New Workspace' })
  190. const wrap = parent.parentElement as HTMLElement
  191. fireEvent.click(parent)
  192. expect(onSelect).not.toHaveBeenCalled()
  193. fireEvent.focus(parent)
  194. fireEvent.mouseEnter(wrap)
  195. expect(screen.getByTestId('sub-ic')).toBeDefined()
  196. fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
  197. expect(onSelect).toHaveBeenCalledWith('ok')
  198. fireEvent.mouseLeave(wrap)
  199. expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
  200. })
  201. it('portal mode prefers getAnchorRect over measuring its own wrapper', () => {
  202. const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect
  203. render(
  204. <Menu
  205. portal
  206. open
  207. getAnchorRect={() => rect}
  208. anchor={null}
  209. items={items}
  210. onSelect={() => {}}
  211. onClose={() => {}}
  212. />)
  213. const menu = screen.getByRole('menu')
  214. // side=bottom, align=start: below the host-supplied rect, left-aligned.
  215. expect(menu.style.left).toBe('40px')
  216. expect(menu.style.top).toBe('132px')
  217. })
  218. it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
  219. render(
  220. <Menu
  221. portal
  222. open
  223. getAnchorRect={() => null}
  224. anchor={null}
  225. items={items}
  226. onSelect={() => {}}
  227. onClose={() => {}}
  228. />)
  229. expect(screen.queryByRole('menu')).toBeNull()
  230. })
  231. it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => {
  232. const onSelect = vi.fn()
  233. const onClose = vi.fn()
  234. const { container } = render(
  235. <Menu portal open anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={onClose} />)
  236. const menu = screen.getByRole('menu')
  237. // Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it.
  238. expect(container.contains(menu)).toBe(false)
  239. expect(menu.parentElement).toBe(document.body)
  240. expect(menu.style.top).not.toBe('')
  241. fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
  242. expect(onSelect).toHaveBeenCalledWith('a')
  243. fireEvent.pointerDown(menu)
  244. expect(onClose).not.toHaveBeenCalled()
  245. // Non-Node targets (e.g. window itself) are ignored, not treated as outside.
  246. const nonNodeTarget = new Event('pointerdown', { bubbles: true })
  247. Object.defineProperty(nonNodeTarget, 'target', { value: window })
  248. document.dispatchEvent(nonNodeTarget)
  249. expect(onClose).not.toHaveBeenCalled()
  250. fireEvent.pointerDown(document.body)
  251. expect(onClose).toHaveBeenCalledTimes(1)
  252. })
  253. it('portal mode positions from the opposite edges for align=end / side=top', () => {
  254. render(
  255. <Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
  256. const menu = screen.getByRole('menu')
  257. expect(menu.style.right).not.toBe('')
  258. expect(menu.style.bottom).not.toBe('')
  259. expect(menu.style.left).toBe('')
  260. expect(menu.style.top).toBe('')
  261. })
  262. })
  263. describe('Modal', () => {
  264. it('is absent while closed; Escape and mask click call onClose', () => {
  265. const onClose = vi.fn()
  266. const { rerender } = render(
  267. <Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
  268. expect(screen.queryByRole('dialog')).toBeNull()
  269. rerender(
  270. <Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
  271. <input aria-label="name" />
  272. </Modal>)
  273. expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
  274. expect(screen.getByText('Name it.')).toBeDefined()
  275. fireEvent.keyDown(document, { key: 'a' })
  276. expect(onClose).not.toHaveBeenCalled()
  277. fireEvent.keyDown(document, { key: 'Escape' })
  278. expect(onClose).toHaveBeenCalledTimes(1)
  279. // Mask is the presentation sibling behind the dialog.
  280. const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
  281. fireEvent.click(mask)
  282. expect(onClose).toHaveBeenCalledTimes(2)
  283. })
  284. })
  285. describe('ConnectionBanner', () => {
  286. it('renders only while reconnecting', () => {
  287. const { container, rerender } = render(<ConnectionBanner reconnecting={false} />)
  288. expect(container.firstChild).toBeNull()
  289. rerender(<ConnectionBanner reconnecting />)
  290. expect(container.textContent).toContain('重连')
  291. })
  292. })