1
0

tree.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { describe, expect, it } from 'vitest'
  2. import type {
  3. SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
  4. } from '@deepseek-ai/dsh-client-runtime/client'
  5. import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
  6. import { createWorkspaceViewStore } from '../src/client/stores.ts'
  7. const sid = (id: string) => id as SessionId
  8. const wid = (id: string) => id as WorkspaceId
  9. const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
  10. id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
  11. })
  12. const list = (...items: SessionSummary[]): SessionListState => ({
  13. ids: items.map(item => item.id),
  14. byId: Object.fromEntries(items.map(item => [item.id, item])),
  15. current: undefined,
  16. phase: 'ready',
  17. intent: undefined,
  18. })
  19. const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
  20. workspaceId: wid(id), path: `/projects/${id}`, title: id,
  21. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  22. })
  23. const view = (expandedProjects: readonly string[] = [], query = '') => ({
  24. expandedProjects, expandedSessions: [] as string[], query,
  25. })
  26. describe('deriveGroups', () => {
  27. it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
  28. const sessions = list(summary('newer', 20), summary('older', 10))
  29. const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
  30. const groups = deriveGroups(sessions, workspaces, view(['first']))
  31. expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
  32. expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
  33. })
  34. it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
  35. const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
  36. const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
  37. expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
  38. expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
  39. })
  40. it('shows one frontend Session row only under a real target Workspace', () => {
  41. const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
  42. const target = workspace('first', [])
  43. expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
  44. intentHere: true,
  45. sessionCount: 1,
  46. containsCurrent: true,
  47. }))
  48. const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
  49. expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
  50. })
  51. it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
  52. const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
  53. const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
  54. expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false }))
  55. })
  56. it('search filters real Sessions and omits the Intent placeholder', () => {
  57. const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
  58. const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
  59. expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
  60. expect(groups[0]!.intentHere).toBe(false)
  61. expect(groups[0]!.sessionCount).toBe(2)
  62. })
  63. it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
  64. const parent = summary('parent', 1)
  65. const oldChild = { ...summary('old-child', 10), parentId: parent.id }
  66. const newChild = { ...summary('new-child', 20), parentId: parent.id }
  67. const tieB = { ...summary('tie-b', 20), parentId: parent.id }
  68. const tieA = { ...summary('tie-a', 20), parentId: parent.id }
  69. const self = { ...summary('self', 2), parentId: sid('self') }
  70. const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
  71. const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
  72. const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
  73. const groups = deriveGroups(
  74. list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
  75. [],
  76. { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
  77. )
  78. expect(groups).toHaveLength(1)
  79. expect(groups[0]!.sessions.map(node => node.id)).toEqual([
  80. sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
  81. ])
  82. expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
  83. newChild.id, tieA.id, tieB.id, oldChild.id,
  84. ])
  85. // Equal timestamps use ids as a deterministic tiebreak in either input order.
  86. expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
  87. .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
  88. })
  89. it('tolerates Workspace membership arriving before its Session summary', () => {
  90. const partial: SessionListState = {
  91. ...list(),
  92. ids: [sid('present')],
  93. byId: { [sid('present')]: summary('present', 1) },
  94. }
  95. const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
  96. expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
  97. })
  98. it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
  99. const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
  100. const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
  101. const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
  102. const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
  103. const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
  104. const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
  105. const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
  106. const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
  107. const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
  108. expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
  109. root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
  110. ])
  111. const labelOnly = deriveGroups(
  112. list(summary('hidden', 1)),
  113. [workspace('label-hit', ['hidden']), workspace('other', [])],
  114. view([], 'label'),
  115. )
  116. expect(labelOnly).toEqual([
  117. expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
  118. ])
  119. })
  120. it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
  121. const owned = summary('owned', 1)
  122. const loose = summary('loose', 2)
  123. const ws = workspace('project', ['owned'])
  124. const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
  125. expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
  126. const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
  127. expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
  128. })
  129. })
  130. describe('deriveFlat', () => {
  131. it('flattens every session — fork children included — newest-first with id tiebreak', () => {
  132. const parent = summary('parent', 10)
  133. const child = { ...summary('child', 30), parentId: parent.id }
  134. const tieB = summary('tie-b', 20)
  135. const tieA = summary('tie-a', 20)
  136. const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
  137. expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
  138. // Rows are branch-free: no children, no expansion.
  139. expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
  140. })
  141. it('search filters by case-insensitive display-title substring', () => {
  142. const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
  143. const miss = { ...summary('miss', 1), displayTitle: 'Other' }
  144. expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
  145. })
  146. it('tolerates ids whose summary has not landed yet', () => {
  147. const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
  148. expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
  149. })
  150. })
  151. describe('createWorkspaceViewStore', () => {
  152. it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
  153. const store = createWorkspaceViewStore().create()
  154. expect(store.getSnapshot().groupBy).toBe('workspace')
  155. store.actions.setGroupBy('flat')
  156. expect(store.getSnapshot().groupBy).toBe('flat')
  157. })
  158. })
  159. describe('projectLabel', () => {
  160. it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
  161. expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
  162. expect(projectLabel('')).toBe(UNGROUPED_LABEL)
  163. expect(projectLabel('/projects/demo/')).toBe('demo')
  164. expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
  165. expect(projectLabel('/')).toBe('/')
  166. })
  167. })
  168. describe('formatRelativeTime', () => {
  169. it('formats current, minute, hour, day, month, and year buckets', () => {
  170. const now = 400 * 24 * 60 * 60 * 1_000
  171. expect(formatRelativeTime(now, now)).toBe('now')
  172. expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
  173. expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
  174. expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
  175. expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
  176. expect(formatRelativeTime(0, now)).toBe('1y')
  177. })
  178. })