tree.spec.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
  3. import {
  4. deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
  5. type SessionRow, type TreeView,
  6. } from '../src/client/tree.ts'
  7. const sid = (s: string) => s as SessionId
  8. /** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
  9. interface SummaryInit {
  10. id: string
  11. title?: string
  12. displayTitle?: string
  13. cwd?: string
  14. parentId?: string
  15. running?: boolean
  16. updatedAt?: number
  17. }
  18. function summary(init: SummaryInit): SessionSummary {
  19. const s: SessionSummary = {
  20. id: sid(init.id),
  21. displayTitle: init.displayTitle ?? init.title ?? init.id,
  22. running: init.running ?? false,
  23. updatedAt: init.updatedAt ?? 0,
  24. }
  25. if (init.title !== undefined) s.title = init.title
  26. if (init.cwd !== undefined) s.cwd = init.cwd
  27. if (init.parentId !== undefined) s.parentId = sid(init.parentId)
  28. return s
  29. }
  30. function listOf(...summaries: SessionSummary[]): SessionListState {
  31. const byId: Record<SessionId, SessionSummary> = {}
  32. for (const s of summaries) byId[s.id] = s
  33. return { ids: summaries.map(s => s.id), byId, current: undefined }
  34. }
  35. const view = (partial: Partial<TreeView> = {}): TreeView => ({
  36. expandedProjects: partial.expandedProjects ?? [],
  37. expandedSessions: partial.expandedSessions ?? [],
  38. query: partial.query ?? '',
  39. })
  40. describe('projectLabel', () => {
  41. it('takes the basename and survives trailing separators', () => {
  42. expect(projectLabel('/home/me/proj')).toBe('proj')
  43. expect(projectLabel('/home/me/proj/')).toBe('proj')
  44. expect(projectLabel('C:\\work\\thing')).toBe('thing')
  45. })
  46. it('falls back for empty and root-only paths', () => {
  47. expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
  48. expect(projectLabel('')).toBe(UNGROUPED_LABEL)
  49. expect(projectLabel('///')).toBe('///')
  50. })
  51. })
  52. describe('deriveRows grouping', () => {
  53. it('groups by cwd into project rows with counts, newest group first', () => {
  54. const rows = deriveRows(listOf(
  55. summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
  56. summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
  57. summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
  58. ), view())
  59. expect(rows).toEqual([
  60. expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
  61. expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
  62. ])
  63. })
  64. it('orders equally-recent groups by label and skips ids missing from byId', () => {
  65. const list = listOf(
  66. summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
  67. summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
  68. // Same basename and same recency as beta: label comparator returns 0,
  69. // insertion order breaks the tie.
  70. summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
  71. )
  72. list.ids.push(sid('ghost'))
  73. const rows = deriveRows(list, view())
  74. expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
  75. })
  76. it('buckets cwd-less sessions under the ungrouped project row', () => {
  77. const rows = deriveRows(listOf(summary({ id: 'a' })), view())
  78. expect(rows).toEqual([
  79. expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
  80. ])
  81. })
  82. it('hides sessions under collapsed projects and shows them when expanded', () => {
  83. const list = listOf(
  84. summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
  85. summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
  86. )
  87. expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
  88. const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
  89. expect(rows.slice(1)).toEqual([
  90. expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
  91. expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
  92. ])
  93. })
  94. })
  95. describe('deriveRows session tree', () => {
  96. const treeList = listOf(
  97. summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
  98. summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
  99. summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
  100. summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
  101. )
  102. it('nests children under expanded parents with increasing depth', () => {
  103. const rows = deriveRows(treeList, view({
  104. expandedProjects: ['/p'],
  105. expandedSessions: ['root', 'kid'],
  106. }))
  107. expect(rows.slice(1)).toEqual([
  108. expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
  109. expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
  110. expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
  111. expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
  112. ])
  113. })
  114. it('collapses subtrees at unexpanded sessions', () => {
  115. const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
  116. const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
  117. expect(ids).toEqual(['other', 'root'])
  118. })
  119. it('degrades a cross-group parent link to a group root', () => {
  120. const rows = deriveRows(listOf(
  121. summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
  122. summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
  123. ), view({ expandedProjects: ['/a', '/b'] }))
  124. expect(rows).toEqual([
  125. expect.objectContaining({ type: 'project', key: '/a' }),
  126. expect.objectContaining({ id: 'p1', depth: 0 }),
  127. expect.objectContaining({ type: 'project', key: '/b' }),
  128. expect.objectContaining({ id: 'stray', depth: 0 }),
  129. ])
  130. })
  131. it('keeps cycle members visible as extra roots without looping', () => {
  132. const rows = deriveRows(listOf(
  133. summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
  134. summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
  135. summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
  136. ), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
  137. const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
  138. expect(ids).toContain('self')
  139. expect(ids).toContain('x')
  140. expect(ids).toContain('y')
  141. expect(ids).toHaveLength(3)
  142. })
  143. it('breaks updatedAt ties deterministically by id', () => {
  144. const rows = deriveRows(listOf(
  145. summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
  146. summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
  147. summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
  148. ), view({ expandedProjects: ['/p'] }))
  149. const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
  150. expect(ids).toEqual(['a', 'b', 'c'])
  151. })
  152. it('collects multiple children under one parent in recency order', () => {
  153. const rows = deriveRows(listOf(
  154. summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
  155. summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
  156. summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
  157. ), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
  158. const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
  159. expect(ids).toEqual(['p', 'new', 'old'])
  160. })
  161. it('carries the running flag onto rows', () => {
  162. const rows = deriveRows(
  163. listOf(summary({ id: 'a', cwd: '/p', running: true })),
  164. view({ expandedProjects: ['/p'] }))
  165. expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
  166. })
  167. })
  168. describe('deriveRows search', () => {
  169. const list = listOf(
  170. summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
  171. summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
  172. summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
  173. summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
  174. )
  175. it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
  176. const rows = deriveRows(list, view({ query: 'NEEDLE' }))
  177. expect(rows).toEqual([
  178. expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
  179. expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
  180. expect.objectContaining({ id: 'kid', depth: 1 }),
  181. ])
  182. })
  183. it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
  184. const rows = deriveRows(list, view({ query: 'other' }))
  185. expect(rows).toEqual([
  186. expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
  187. ])
  188. })
  189. it('blank query means normal mode', () => {
  190. const rows = deriveRows(list, view({ query: ' ' }))
  191. expect(rows.every(r => r.type === 'project')).toBe(true)
  192. })
  193. it('matches the effective display title when no durable title is available', () => {
  194. const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
  195. const rows = deriveRows(fallback, view({ query: 'fallback' }))
  196. expect(rows).toEqual([
  197. expect.objectContaining({ type: 'project', key: '/elsewhere' }),
  198. expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
  199. ])
  200. })
  201. })
  202. describe('formatRelativeTime', () => {
  203. const now = 1_000_000_000_000
  204. it.each([
  205. [now, 'now'],
  206. [now - 30_000, 'now'],
  207. [now - 2 * 60_000, '2min'],
  208. [now - 3_600_000, '1h'],
  209. [now - 2 * 86_400_000, '2d'],
  210. [now - 18 * 86_400_000, '18d'],
  211. [now - 65 * 86_400_000, '2mo'],
  212. [now - 400 * 86_400_000, '1y'],
  213. ])('%d -> %s', (at, label) => {
  214. expect(formatRelativeTime(at, now)).toBe(label)
  215. })
  216. it('clamps future timestamps to now', () => {
  217. expect(formatRelativeTime(now + 5_000, now)).toBe('now')
  218. })
  219. })