rows.spec.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
  4. import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
  5. import type { RowDragProps } from '../src/client/rows/Rows.tsx'
  6. import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
  7. import type { GroupNode, SessionNode } from '../src/client/tree.ts'
  8. afterEach(cleanup)
  9. const sid = (id: string) => id as SessionId
  10. const wid = (id: string) => id as WorkspaceId
  11. /** Half detection reads the row rect; jsdom rects are all-zero by default. */
  12. function stubRect(row: HTMLElement): void {
  13. row.getBoundingClientRect = () => ({
  14. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
  15. x: 0, y: 100, toJSON: () => ({}),
  16. })
  17. }
  18. function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
  19. return {
  20. start: vi.fn(), active: false, marker: null,
  21. hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
  22. ...overrides,
  23. }
  24. }
  25. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  26. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  27. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  28. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  29. Object.defineProperty(event, 'clientY', { value: clientY })
  30. Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
  31. fireEvent(row, event)
  32. }
  33. describe('workspace browser rows', () => {
  34. it('renders an active Workspace and keeps its create action separate from toggling', () => {
  35. const onToggle = vi.fn()
  36. const onCreate = vi.fn()
  37. const group: GroupNode = {
  38. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
  39. sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
  40. }
  41. render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
  42. expect(screen.getByText('1 session')).toBeTruthy()
  43. expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
  44. fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
  45. expect(onCreate).toHaveBeenCalledOnce()
  46. expect(onToggle).not.toHaveBeenCalled()
  47. fireEvent.click(screen.getByText('Project'))
  48. expect(onToggle).toHaveBeenCalledOnce()
  49. })
  50. it('renders and operates selected, running, recursive Session nodes', () => {
  51. const child: SessionNode = {
  52. id: sid('child'), title: 'Child', children: [], hasChildren: false,
  53. expanded: false, running: false, updatedAt: 0,
  54. }
  55. const parent: SessionNode = {
  56. id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
  57. expanded: true, running: true, updatedAt: 0,
  58. }
  59. const onOpen = vi.fn()
  60. const onToggle = vi.fn()
  61. const view = render(
  62. <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
  63. )
  64. const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
  65. const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
  66. expect(parentRow.getAttribute('aria-selected')).toBe('true')
  67. expect(parentRow.getAttribute('aria-expanded')).toBe('true')
  68. expect(childRow.getAttribute('aria-selected')).toBe('false')
  69. expect(childRow.hasAttribute('aria-expanded')).toBe(false)
  70. fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
  71. expect(onToggle).toHaveBeenCalledWith(parent.id)
  72. expect(onOpen).not.toHaveBeenCalled()
  73. fireEvent.click(parentRow)
  74. fireEvent.click(childRow)
  75. expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
  76. view.rerender(
  77. <SessionNodeItem
  78. node={{ ...parent, children: [], expanded: false, running: false }}
  79. depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
  80. />,
  81. )
  82. expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
  83. expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
  84. expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
  85. })
  86. it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
  87. const onRename = vi.fn()
  88. const onDelete = vi.fn()
  89. const onToggle = vi.fn()
  90. const group: GroupNode = {
  91. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
  92. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  93. }
  94. render(<ProjectRowItem
  95. group={group} onToggle={onToggle} onCreate={vi.fn()}
  96. actions={{ rename: onRename, delete: onDelete }}
  97. />)
  98. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
  99. // Opening the menu neither toggles the group nor renames yet.
  100. expect(onToggle).not.toHaveBeenCalled()
  101. expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
  102. fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
  103. expect(onRename).toHaveBeenCalledOnce()
  104. expect(screen.queryByRole('menu')).toBeNull()
  105. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
  106. fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
  107. expect(screen.queryByRole('menu')).toBeNull()
  108. expect(onRename).toHaveBeenCalledOnce()
  109. expect(onDelete).toHaveBeenCalledOnce()
  110. // Escape closes without selecting (Menu onClose path).
  111. fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
  112. fireEvent.keyDown(document, { key: 'Escape' })
  113. expect(screen.queryByRole('menu')).toBeNull()
  114. })
  115. it('ungrouped bucket renders no workspace menu', () => {
  116. const group: GroupNode = {
  117. key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
  118. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  119. }
  120. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
  121. expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
  122. })
  123. it('session row menu opens without opening the session and closes on selection', () => {
  124. const onOpen = vi.fn()
  125. const node: SessionNode = {
  126. id: sid('s1'), title: 'One', children: [], hasChildren: false,
  127. expanded: false, running: false, updatedAt: 0,
  128. }
  129. render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
  130. fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
  131. expect(onOpen).not.toHaveBeenCalled()
  132. expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
  133. fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
  134. expect(screen.queryByRole('menu')).toBeNull()
  135. expect(onOpen).not.toHaveBeenCalled()
  136. // Escape closes without selecting (Menu onClose path).
  137. fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
  138. fireEvent.keyDown(document, { key: 'Escape' })
  139. expect(screen.queryByRole('menu')).toBeNull()
  140. })
  141. it('flat variant renders no twist even for a parent and ignores toggling', () => {
  142. const node: SessionNode = {
  143. id: sid('p'), title: 'Parent', children: [], hasChildren: true,
  144. expanded: false, running: false, updatedAt: 0,
  145. }
  146. render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
  147. expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
  148. })
  149. it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
  150. vi.useFakeTimers()
  151. try {
  152. const node: SessionNode = {
  153. id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
  154. expanded: false, running: true, updatedAt: 0,
  155. }
  156. render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
  157. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  158. fireEvent.pointerEnter(wrapper)
  159. act(() => { vi.advanceTimersByTime(500) })
  160. // Card body: full title + relative time + running status.
  161. expect(screen.getAllByText('Hovered')).toHaveLength(2)
  162. expect(screen.getByText('1min ago')).toBeTruthy()
  163. expect(screen.getByText('Running')).toBeTruthy()
  164. fireEvent.pointerLeave(wrapper)
  165. // Menu open (disabled=true) suppresses the card for the same hover.
  166. fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
  167. fireEvent.pointerEnter(wrapper)
  168. act(() => { vi.advanceTimersByTime(1000) })
  169. expect(screen.queryByText('1min ago')).toBeNull()
  170. } finally {
  171. vi.useRealTimers()
  172. }
  173. })
  174. it('idle hover card shows the Idle status line', () => {
  175. vi.useFakeTimers()
  176. try {
  177. const node: SessionNode = {
  178. id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
  179. expanded: false, running: false, updatedAt: 0,
  180. }
  181. render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
  182. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  183. act(() => { vi.advanceTimersByTime(500) })
  184. expect(screen.getByText('Idle')).toBeTruthy()
  185. expect(screen.getByText('now ago')).toBeTruthy()
  186. } finally {
  187. vi.useRealTimers()
  188. }
  189. })
  190. it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
  191. const node: SessionNode = {
  192. id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
  193. expanded: false, running: false, updatedAt: 0,
  194. }
  195. const inactive = dragProps()
  196. const { rerender } = render(
  197. <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
  198. )
  199. const row = screen.getByRole('treeitem')
  200. stubRect(row)
  201. expect(row.getAttribute('draggable')).toBe('true')
  202. fireEvent.dragStart(row, { dataTransfer })
  203. expect(inactive.start).toHaveBeenCalledOnce()
  204. // Inactive drag: hover and drop are rejected.
  205. fireEvent.dragOver(row, { dataTransfer })
  206. fireEvent.drop(row, { dataTransfer })
  207. expect(inactive.hover).not.toHaveBeenCalled()
  208. expect(inactive.drop).not.toHaveBeenCalled()
  209. fireEvent.dragEnd(row)
  210. expect(inactive.end).toHaveBeenCalledOnce()
  211. const active = dragProps({ active: true, marker: 'before' })
  212. rerender(
  213. <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
  214. )
  215. stubRect(screen.getByRole('treeitem'))
  216. // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
  217. fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
  218. expect(active.hover).toHaveBeenCalledWith('before')
  219. fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
  220. expect(active.hover).toHaveBeenCalledWith('after')
  221. fireDrag(screen.getByRole('treeitem'), 'drop', 130)
  222. expect(active.drop).toHaveBeenCalledWith('after')
  223. const after = dragProps({ active: true, marker: 'after' })
  224. rerender(
  225. <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
  226. )
  227. expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
  228. })
  229. })