rows.spec.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  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. /** Install the async browser clipboard and restore its prior host shape. */
  31. function installClipboard(writeText: (text: string) => Promise<void>): () => void {
  32. const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
  33. Object.defineProperty(navigator, 'clipboard', {
  34. configurable: true,
  35. value: { writeText },
  36. })
  37. return () => {
  38. if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
  39. else Object.defineProperty(navigator, 'clipboard', prior)
  40. }
  41. }
  42. const dataTransfer = { effectAllowed: '', dropEffect: '' }
  43. /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
  44. function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
  45. const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
  46. Object.defineProperty(event, 'clientY', { value: clientY })
  47. Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
  48. fireEvent(row, event)
  49. }
  50. describe('workspace browser rows', () => {
  51. it('renders a selected content-search row and opens only its session', () => {
  52. const onOpen = vi.fn()
  53. const result: SearchResultNode = {
  54. id: sid('result'),
  55. title: 'Result title',
  56. workspace: 'Workspace context',
  57. running: true,
  58. completed: false,
  59. snippet: 'matching message excerpt',
  60. }
  61. render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} t={t} />)
  62. const row = screen.getByRole('treeitem')
  63. expect(row.getAttribute('aria-selected')).toBe('true')
  64. expect(screen.getByText('Workspace context')).toBeTruthy()
  65. expect(screen.getByText('matching message excerpt')).toBeTruthy()
  66. expect(row.querySelector('[data-state="ongoing"]')).toBeTruthy()
  67. expect(screen.getByText('进行中')).toBeTruthy()
  68. expect(row.hasAttribute('draggable')).toBe(false)
  69. fireEvent.click(row)
  70. expect(onOpen).toHaveBeenCalledWith(result.id)
  71. })
  72. it.each([
  73. ['approval', '等待审批'],
  74. ['plan-review', '计划待审'],
  75. ['question', '等待回答'],
  76. ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => {
  77. const result: SearchResultNode = {
  78. id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
  79. pendingInteraction, running: true, completed: false,
  80. }
  81. render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} t={t} />)
  82. const row = screen.getByRole('treeitem')
  83. expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
  84. expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
  85. expect(screen.getByText(label)).toBeTruthy()
  86. })
  87. it('renders an active Workspace and keeps its create action separate from toggling', () => {
  88. const onToggle = vi.fn()
  89. const onCreate = vi.fn()
  90. const group: GroupNode = {
  91. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  92. sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
  93. }
  94. render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
  95. expect(screen.getByText('1 个会话')).toBeTruthy()
  96. expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
  97. fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
  98. expect(onCreate).toHaveBeenCalledOnce()
  99. expect(onToggle).not.toHaveBeenCalled()
  100. fireEvent.click(screen.getByText('Project'))
  101. expect(onToggle).toHaveBeenCalledOnce()
  102. })
  103. it('renders and opens a selected running Session row', () => {
  104. const node: SessionNode = {
  105. id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0,
  106. }
  107. const onOpen = vi.fn()
  108. render(
  109. <SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
  110. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
  111. )
  112. const row = screen.getByRole('treeitem')
  113. expect(row.getAttribute('aria-selected')).toBe('true')
  114. expect(row.hasAttribute('aria-expanded')).toBe(false)
  115. expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
  116. fireEvent.click(row)
  117. expect(onOpen).toHaveBeenCalledWith(node.id)
  118. })
  119. it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => {
  120. const renderRow = (over: Partial<SessionNode>) => render(
  121. <SessionNodeItem
  122. node={{ id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, ...over }}
  123. currentId={undefined} now={0} onOpen={vi.fn()}
  124. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
  125. />,
  126. )
  127. const stateDot = (view: ReturnType<typeof renderRow>) =>
  128. view.container.querySelector('[data-state]')
  129. // No completion reminder, not running: no state dot at all.
  130. const plain = renderRow({})
  131. expect(stateDot(plain)).toBeNull()
  132. plain.unmount()
  133. // Completed while unviewed: the green done dot.
  134. const done = renderRow({ completed: true })
  135. expect(done.container.querySelector('[data-state="done"]')).not.toBeNull()
  136. done.unmount()
  137. // Running wins the slot: the animated ongoing dot, no done dot.
  138. const running = renderRow({ completed: true, running: true })
  139. expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
  140. expect(running.container.querySelector('[data-state="done"]')).toBeNull()
  141. })
  142. it('shows the green done dot on a finished search result row', () => {
  143. render(<SearchResultItem
  144. result={{ id: sid('result'), title: 'Done', workspace: 'Workspace', running: false, completed: true }}
  145. currentId={undefined} onOpen={vi.fn()} t={t}
  146. />)
  147. expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
  148. })
  149. it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
  150. const onRename = vi.fn()
  151. const onDelete = vi.fn()
  152. const onToggle = vi.fn()
  153. const group: GroupNode = {
  154. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  155. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  156. }
  157. render(<ProjectRowItem
  158. group={group} onToggle={onToggle} onCreate={vi.fn()}
  159. actions={{ rename: onRename, delete: onDelete }} t={t}
  160. />)
  161. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  162. // Opening the menu neither toggles the group nor renames yet.
  163. expect(onToggle).not.toHaveBeenCalled()
  164. expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
  165. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  166. expect(onRename).toHaveBeenCalledOnce()
  167. expect(screen.queryByRole('menu')).toBeNull()
  168. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  169. fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
  170. expect(screen.queryByRole('menu')).toBeNull()
  171. expect(onRename).toHaveBeenCalledOnce()
  172. expect(onDelete).toHaveBeenCalledOnce()
  173. // Escape closes without selecting (Menu onClose path).
  174. fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
  175. fireEvent.keyDown(document, { key: 'Escape' })
  176. expect(screen.queryByRole('menu')).toBeNull()
  177. })
  178. it('workspace hover card shows its details and copies the full directory path', async () => {
  179. vi.useFakeTimers()
  180. const writeText = vi.fn(async () => {})
  181. const restoreClipboard = installClipboard(writeText)
  182. try {
  183. const group: GroupNode = {
  184. key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
  185. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  186. }
  187. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  188. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  189. act(() => { vi.advanceTimersByTime(500) })
  190. // Card body: full title + cwd + absolute creation time.
  191. expect(screen.getAllByText('Project')).toHaveLength(2)
  192. expect(screen.getByText('/projects/project')).toBeTruthy()
  193. expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
  194. await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
  195. expect(writeText).toHaveBeenCalledWith('/projects/project')
  196. expect(screen.getByRole('status').textContent).toBe('已复制')
  197. } finally {
  198. restoreClipboard()
  199. vi.useRealTimers()
  200. }
  201. })
  202. it('ungrouped bucket renders no workspace menu', () => {
  203. const group: GroupNode = {
  204. key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
  205. sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
  206. }
  207. render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
  208. expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
  209. })
  210. it('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
  211. vi.useFakeTimers()
  212. try {
  213. const node: SessionNode = {
  214. id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0,
  215. }
  216. render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
  217. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  218. // The placeholder has no content yet: no row verbs, no "now" stamp.
  219. expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
  220. expect(screen.queryByText('刚刚')).toBeNull()
  221. // The hover card keeps title + status but drops the timestamp line.
  222. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  223. fireEvent.pointerEnter(wrapper)
  224. act(() => { vi.advanceTimersByTime(500) })
  225. expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
  226. expect(screen.getByText('空闲')).toBeTruthy()
  227. expect(screen.queryByText('刚刚')).toBeNull()
  228. expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
  229. } finally {
  230. vi.useRealTimers()
  231. }
  232. })
  233. it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
  234. const onOpen = vi.fn()
  235. const onRename = vi.fn()
  236. const onFork = vi.fn()
  237. const onArchive = vi.fn()
  238. const node: SessionNode = {
  239. id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0,
  240. }
  241. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
  242. onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
  243. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  244. expect(onOpen).not.toHaveBeenCalled()
  245. // Archive is not destructive (log and accounting slot remain): no danger styling.
  246. expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
  247. // Rename dispatches with the current display title (dialog prefill).
  248. fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
  249. expect(screen.queryByRole('menu')).toBeNull()
  250. expect(onRename).toHaveBeenCalledWith(node.id, 'One')
  251. expect(onOpen).not.toHaveBeenCalled()
  252. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  253. fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
  254. expect(onFork).toHaveBeenCalledWith(node.id)
  255. // Archive dispatches without opening the session.
  256. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  257. fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
  258. expect(onArchive).toHaveBeenCalledWith(node.id)
  259. expect(onRename).toHaveBeenCalledOnce()
  260. expect(onOpen).not.toHaveBeenCalled()
  261. // Escape closes without selecting (Menu onClose path).
  262. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
  263. fireEvent.keyDown(document, { key: 'Escape' })
  264. expect(screen.queryByRole('menu')).toBeNull()
  265. })
  266. it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
  267. vi.useFakeTimers()
  268. try {
  269. const node: SessionNode = {
  270. id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0,
  271. }
  272. render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
  273. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  274. const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
  275. fireEvent.pointerEnter(wrapper)
  276. act(() => { vi.advanceTimersByTime(500) })
  277. // Card body: full title + relative time + running status.
  278. expect(screen.getAllByText('Hovered')).toHaveLength(2)
  279. expect(screen.getByText('1分钟前')).toBeTruthy()
  280. expect(screen.getAllByText('进行中')).toHaveLength(2)
  281. fireEvent.pointerLeave(wrapper)
  282. // Menu open (disabled=true) suppresses the card for the same hover.
  283. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
  284. fireEvent.pointerEnter(wrapper)
  285. act(() => { vi.advanceTimersByTime(1000) })
  286. expect(screen.queryByText('1分钟前')).toBeNull()
  287. } finally {
  288. vi.useRealTimers()
  289. }
  290. })
  291. it.each([
  292. ['approval', '等待审批'],
  293. ['plan-review', '计划待审'],
  294. ['question', '等待回答'],
  295. ] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => {
  296. vi.useFakeTimers()
  297. try {
  298. const node: SessionNode = {
  299. id: sid(pendingInteraction), title: 'Needs input', blank: false,
  300. pendingInteraction, running: true, completed: false, updatedAt: 0,
  301. }
  302. const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  303. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  304. const row = screen.getByRole('treeitem')
  305. expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
  306. expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
  307. expect(screen.getByText(label)).toBeTruthy()
  308. view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
  309. onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  310. expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
  311. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  312. act(() => { vi.advanceTimersByTime(500) })
  313. expect(screen.getAllByText(label)).toHaveLength(2)
  314. expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
  315. } finally {
  316. vi.useRealTimers()
  317. }
  318. })
  319. it('idle hover card shows the Idle status line', () => {
  320. vi.useFakeTimers()
  321. try {
  322. const node: SessionNode = {
  323. id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0,
  324. }
  325. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  326. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  327. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  328. act(() => { vi.advanceTimersByTime(500) })
  329. expect(screen.getByText('空闲')).toBeTruthy()
  330. expect(screen.getAllByText('刚刚')).toHaveLength(2)
  331. } finally {
  332. vi.useRealTimers()
  333. }
  334. })
  335. it('completed hover card shows the Completed status line', () => {
  336. vi.useFakeTimers()
  337. try {
  338. const node: SessionNode = {
  339. id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0,
  340. }
  341. render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  342. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
  343. fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
  344. act(() => { vi.advanceTimersByTime(500) })
  345. // Row's visually-hidden reminder label plus the hover card's status line.
  346. expect(screen.getAllByText('已完成')).toHaveLength(2)
  347. } finally {
  348. vi.useRealTimers()
  349. }
  350. })
  351. it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
  352. const node: SessionNode = {
  353. id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0,
  354. }
  355. const inactive = dragProps()
  356. const { rerender } = render(
  357. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  358. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
  359. )
  360. const row = screen.getByRole('treeitem')
  361. stubRect(row)
  362. expect(row.getAttribute('draggable')).toBe('true')
  363. fireEvent.dragStart(row, { dataTransfer })
  364. expect(inactive.start).toHaveBeenCalledOnce()
  365. // Inactive drag: hover and drop are rejected.
  366. fireEvent.dragOver(row, { dataTransfer })
  367. fireEvent.drop(row, { dataTransfer })
  368. expect(inactive.hover).not.toHaveBeenCalled()
  369. expect(inactive.drop).not.toHaveBeenCalled()
  370. fireEvent.dragEnd(row)
  371. expect(inactive.end).toHaveBeenCalledOnce()
  372. const active = dragProps({ active: true, marker: 'before' })
  373. rerender(
  374. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  375. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
  376. )
  377. stubRect(screen.getByRole('treeitem'))
  378. // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
  379. fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
  380. expect(active.hover).toHaveBeenCalledWith('before')
  381. fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
  382. expect(active.hover).toHaveBeenCalledWith('after')
  383. fireDrag(screen.getByRole('treeitem'), 'drop', 130)
  384. expect(active.drop).toHaveBeenCalledWith('after')
  385. const after = dragProps({ active: true, marker: 'after' })
  386. rerender(
  387. <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
  388. onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
  389. )
  390. expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
  391. })
  392. })