rows.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  7. import type { RowDragProps } from '../src/client/rows/Rows.tsx'
  8. import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
  9. import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
  10. import { zh } from '../src/client/locales.ts'
  11. afterEach(cleanup)
  12. // Standard locale seat stub mirroring the real ns → common → key chain (zh default).
  13. const t = makeTranslate(zh, commonZh) as never
  14. const sid = (id: string) => id as SessionId
  15. const wid = (id: string) => id as WorkspaceId
  16. /** Half detection reads the row rect; jsdom rects are all-zero by default. */
  17. function stubRect(row: HTMLElement): void {
  18. row.getBoundingClientRect = () => ({
  19. top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
  20. x: 0, y: 100, toJSON: () => ({}),
  21. })
  22. }
  23. function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
  24. return {
  25. start: vi.fn(), active: false, marker: null,
  26. hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
  27. ...overrides,
  28. }
  29. }
  30. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  31. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  32. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  33. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  34. Object.defineProperty(event, 'clientY', { value: clientY })
  35. Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
  36. fireEvent(row, event)
  37. }
  38. describe('workspace browser rows', () => {
  39. it('renders a selected content-search row and opens only its session', () => {
  40. const onOpen = vi.fn()
  41. const result: SearchResultNode = {
  42. id: sid('result'),
  43. title: 'Result title',
  44. workspace: 'Workspace context',
  45. running: true,
  46. snippet: 'matching message excerpt',
  47. }
  48. render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} />)
  49. const row = screen.getByRole('treeitem')
  50. expect(row.getAttribute('aria-selected')).toBe('true')
  51. expect(screen.getByText('Workspace context')).toBeTruthy()
  52. expect(screen.getByText('matching message excerpt')).toBeTruthy()
  53. expect(row.hasAttribute('draggable')).toBe(false)
  54. fireEvent.click(row)
  55. expect(onOpen).toHaveBeenCalledWith(result.id)
  56. })
  57. it('renders an active Workspace and keeps its create action separate from toggling', () => {
  58. const onToggle = vi.fn()
  59. const onCreate = vi.fn()
  60. const group: GroupNode = {
  61. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  62. sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
  63. }
  64. render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
  65. expect(screen.getByText('1 个会话')).toBeTruthy()
  66. expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
  67. fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
  68. expect(onCreate).toHaveBeenCalledOnce()
  69. expect(onToggle).not.toHaveBeenCalled()
  70. fireEvent.click(screen.getByText('Project'))
  71. expect(onToggle).toHaveBeenCalledOnce()
  72. })
  73. it('renders and opens a selected running Session row', () => {
  74. const node: SessionNode = {
  75. id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
  76. }
  77. const onOpen = vi.fn()
  78. render(
  79. <SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
  80. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
  81. )
  82. const row = screen.getByRole('treeitem')
  83. expect(row.getAttribute('aria-selected')).toBe('true')
  84. expect(row.hasAttribute('aria-expanded')).toBe(false)
  85. expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
  86. fireEvent.click(row)
  87. expect(onOpen).toHaveBeenCalledWith(node.id)
  88. })
  89. it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
  90. const onRename = vi.fn()
  91. const onDelete = vi.fn()
  92. const onToggle = vi.fn()
  93. const group: GroupNode = {
  94. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  95. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  96. }
  97. render(<ProjectRowItem
  98. group={group} onToggle={onToggle} onCreate={vi.fn()}
  99. actions={{ rename: onRename, delete: onDelete }} t={t}
  100. />)
  101. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  102. // Opening the menu neither toggles the group nor renames yet.
  103. expect(onToggle).not.toHaveBeenCalled()
  104. expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
  105. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  106. expect(onRename).toHaveBeenCalledOnce()
  107. expect(screen.queryByRole('menu')).toBeNull()
  108. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  109. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  110. expect(screen.queryByRole('menu')).toBeNull()
  111. expect(onRename).toHaveBeenCalledOnce()
  112. expect(onDelete).toHaveBeenCalledOnce()
  113. // Escape closes without selecting (Menu onClose path).
  114. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  115. fireEvent.keyDown(document, { key: 'Escape' })
  116. expect(screen.queryByRole('menu')).toBeNull()
  117. })
  118. it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
  119. vi.useFakeTimers()
  120. try {
  121. const group: GroupNode = {
  122. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  123. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  124. }
  125. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  126. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  127. act(() => { vi.advanceTimersByTime(500) })
  128. // Card body: full title + cwd + absolute creation time.
  129. expect(screen.getAllByText('Project')).toHaveLength(2)
  130. expect(screen.getByText('/projects/project')).toBeTruthy()
  131. expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
  132. } finally {
  133. vi.useRealTimers()
  134. }
  135. })
  136. it('ungrouped bucket renders no workspace menu', () => {
  137. const group: GroupNode = {
  138. key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
  139. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  140. }
  141. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  142. expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
  143. })
  144. it('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
  145. vi.useFakeTimers()
  146. try {
  147. const node: SessionNode = {
  148. id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
  149. }
  150. render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
  151. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  152. // The placeholder has no content yet: no row verbs, no "now" stamp.
  153. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
  154. expect(screen.queryByText('刚刚')).toBeNull()
  155. // The hover card keeps title + status but drops the timestamp line.
  156. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  157. fireEvent.pointerEnter(wrapper)
  158. act(() => { vi.advanceTimersByTime(500) })
  159. expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
  160. expect(screen.getByText('空闲')).toBeTruthy()
  161. expect(screen.queryByText('刚刚')).toBeNull()
  162. } finally {
  163. vi.useRealTimers()
  164. }
  165. })
  166. it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
  167. const onOpen = vi.fn()
  168. const onRename = vi.fn()
  169. const onFork = vi.fn()
  170. const onArchive = vi.fn()
  171. const node: SessionNode = {
  172. id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
  173. }
  174. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
  175. onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
  176. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  177. expect(onOpen).not.toHaveBeenCalled()
  178. // Archive is not destructive (log and accounting slot remain): no danger styling.
  179. expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
  180. // Rename dispatches with the current display title (dialog prefill).
  181. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  182. expect(screen.queryByRole('menu')).toBeNull()
  183. expect(onRename).toHaveBeenCalledWith(node.id, 'One')
  184. expect(onOpen).not.toHaveBeenCalled()
  185. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  186. fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
  187. expect(onFork).toHaveBeenCalledWith(node.id)
  188. // Archive dispatches without opening the session.
  189. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  190. fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
  191. expect(onArchive).toHaveBeenCalledWith(node.id)
  192. expect(onRename).toHaveBeenCalledOnce()
  193. expect(onOpen).not.toHaveBeenCalled()
  194. // Escape closes without selecting (Menu onClose path).
  195. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  196. fireEvent.keyDown(document, { key: 'Escape' })
  197. expect(screen.queryByRole('menu')).toBeNull()
  198. })
  199. it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
  200. vi.useFakeTimers()
  201. try {
  202. const node: SessionNode = {
  203. id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
  204. }
  205. render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
  206. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  207. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  208. fireEvent.pointerEnter(wrapper)
  209. act(() => { vi.advanceTimersByTime(500) })
  210. // Card body: full title + relative time + running status.
  211. expect(screen.getAllByText('Hovered')).toHaveLength(2)
  212. expect(screen.getByText('1分钟前')).toBeTruthy()
  213. expect(screen.getByText('进行中')).toBeTruthy()
  214. fireEvent.pointerLeave(wrapper)
  215. // Menu open (disabled=true) suppresses the card for the same hover.
  216. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
  217. fireEvent.pointerEnter(wrapper)
  218. act(() => { vi.advanceTimersByTime(1000) })
  219. expect(screen.queryByText('1分钟前')).toBeNull()
  220. } finally {
  221. vi.useRealTimers()
  222. }
  223. })
  224. it('idle hover card shows the Idle status line', () => {
  225. vi.useFakeTimers()
  226. try {
  227. const node: SessionNode = {
  228. id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
  229. }
  230. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  231. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  232. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  233. act(() => { vi.advanceTimersByTime(500) })
  234. expect(screen.getByText('空闲')).toBeTruthy()
  235. expect(screen.getAllByText('刚刚')).toHaveLength(2)
  236. } finally {
  237. vi.useRealTimers()
  238. }
  239. })
  240. it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
  241. const node: SessionNode = {
  242. id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
  243. }
  244. const inactive = dragProps()
  245. const { rerender } = render(
  246. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  247. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
  248. )
  249. const row = screen.getByRole('treeitem')
  250. stubRect(row)
  251. expect(row.getAttribute('draggable')).toBe('true')
  252. fireEvent.dragStart(row, { dataTransfer })
  253. expect(inactive.start).toHaveBeenCalledOnce()
  254. // Inactive drag: hover and drop are rejected.
  255. fireEvent.dragOver(row, { dataTransfer })
  256. fireEvent.drop(row, { dataTransfer })
  257. expect(inactive.hover).not.toHaveBeenCalled()
  258. expect(inactive.drop).not.toHaveBeenCalled()
  259. fireEvent.dragEnd(row)
  260. expect(inactive.end).toHaveBeenCalledOnce()
  261. const active = dragProps({ active: true, marker: 'before' })
  262. rerender(
  263. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  264. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
  265. )
  266. stubRect(screen.getByRole('treeitem'))
  267. // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
  268. fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
  269. expect(active.hover).toHaveBeenCalledWith('before')
  270. fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
  271. expect(active.hover).toHaveBeenCalledWith('after')
  272. fireDrag(screen.getByRole('treeitem'), 'drop', 130)
  273. expect(active.drop).toHaveBeenCalledWith('after')
  274. const after = dragProps({ active: true, marker: 'after' })
  275. rerender(
  276. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  277. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
  278. )
  279. expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
  280. })
  281. })