tree.client.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
  3. import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
  4. import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
  5. import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
  6. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  7. import {
  8. deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, workspaceLabel,
  9. UNGROUPED_KEY,
  10. } from '../src/client/tree.ts'
  11. import { createWorkspaceViewStore } from '../src/client/stores.ts'
  12. const sid = (id: string) => id as SessionId
  13. const wid = (id: string) => id as WorkspaceId
  14. const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
  15. id: sid(id), displayTitle: id, running: false, blank: false,
  16. updatedAt, ...(cwd === undefined ? {} : { cwd }),
  17. })
  18. const list = (...items: SessionSummary[]): SessionListState => ({
  19. ids: items.map(item => item.id),
  20. byId: Object.fromEntries(items.map(item => [item.id, item])),
  21. current: undefined,
  22. phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
  23. })
  24. const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
  25. workspaceId: wid(id), path: `/projects/${id}`, title,
  26. sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  27. })
  28. const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
  29. expandedGroups,
  30. ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
  31. })
  32. const noArchive: readonly SessionId[] = []
  33. const noAttention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
  34. const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
  35. const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({
  36. id: id as ScheduleId,
  37. kind: 'at',
  38. prompt: id,
  39. scheduledAt,
  40. })
  41. describe('owningGroupKey', () => {
  42. it('returns the owning Workspace id or the Ungrouped key', () => {
  43. const workspaces = [workspace('first', ['owned'])]
  44. expect(owningGroupKey(workspaces, sid('owned'))).toBe('first')
  45. expect(owningGroupKey(workspaces, sid('loose'))).toBe(UNGROUPED_KEY)
  46. })
  47. })
  48. describe('deriveGroups', () => {
  49. it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
  50. const sessions = list(summary('newer', 20), summary('older', 10))
  51. const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
  52. const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first']))
  53. expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
  54. expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
  55. })
  56. it('projects pending-interaction state into grouped and flat rows', () => {
  57. const awaiting = { ...summary('awaiting', 10), running: true }
  58. const sessions = list(awaiting)
  59. const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
  60. awaiting.id,
  61. { key: 'question:1', kind: 'plan-review', sessionId: awaiting.id },
  62. ]])
  63. const grouped = deriveGroups(
  64. sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
  65. )
  66. expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
  67. expect(deriveFlat(sessions, noArchive, attention)[0])
  68. .toMatchObject({ pendingInteraction: 'plan-review', running: true })
  69. })
  70. it.each(['approval', 'question'] as const)(
  71. 'projects the %s pending-interaction kind',
  72. (kind) => {
  73. const awaiting = summary(kind, 10)
  74. const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
  75. awaiting.id,
  76. { key: `${kind}:1`, kind, sessionId: awaiting.id },
  77. ]])
  78. expect(deriveFlat(list(awaiting), noArchive, attention)[0]?.pendingInteraction).toBe(kind)
  79. },
  80. )
  81. it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
  82. const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
  83. const groups = deriveGroups(
  84. sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]),
  85. )
  86. expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
  87. expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
  88. })
  89. it('applies stored Ungrouped order and appends new loose Sessions by recency', () => {
  90. const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4))
  91. const groups = deriveGroups(
  92. sessions,
  93. [],
  94. noArchive,
  95. noAttention,
  96. view([UNGROUPED_KEY], ['two', 'stale', 'two']),
  97. )
  98. expect(groups[0]!.sessions.map(session => session.id)).toEqual([
  99. sid('two'), sid('new'), sid('one'),
  100. ])
  101. })
  102. it('shows only the current blank session in its Workspace count and tree', () => {
  103. const currentBlank = { ...summary('current-blank', 5), blank: true }
  104. const staleBlank = { ...summary('stale-blank', 4), blank: true }
  105. const real = summary('shown', 3)
  106. const sessions = {
  107. ...list(real, currentBlank, staleBlank),
  108. current: currentBlank.id,
  109. }
  110. const groups = deriveGroups(
  111. sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
  112. noArchive, noAttention, view(['first']),
  113. )
  114. expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
  115. const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
  116. // The stored placeholder title stays canonical; the renderer swaps in
  117. // the localized New Session label via the blank flag.
  118. expect(blankNode.title).toBe('')
  119. expect(blankNode.blank).toBe(true)
  120. expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
  121. expect(groups[0]!.sessionCount).toBe(2)
  122. // A non-current blank stray never surfaces an Ungrouped bucket either.
  123. const strayGroups = deriveGroups(
  124. list({ ...summary('stray', 2), blank: true }),
  125. [workspace('first', [])], noArchive, noAttention, view(),
  126. )
  127. expect(strayGroups.map(group => group.key)).toEqual(['first'])
  128. })
  129. it('projects the completion reminder into session and search rows (absent = false)', () => {
  130. const done = { ...summary('done', 3), completed: true }
  131. const plain = summary('plain', 2)
  132. const sessions = list(done, plain)
  133. const groups = deriveGroups(
  134. sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']),
  135. )
  136. const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
  137. const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
  138. expect(doneNode.completed).toBe(true)
  139. expect(plainNode.completed).toBe(false)
  140. expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true)
  141. const search = deriveSearchResults(
  142. sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
  143. noAttention, { items: [], hasMore: false }, 10,
  144. )
  145. expect(search.items[0]?.completed).toBe(true)
  146. })
  147. it('derives one active-Schedule fact for grouped, flat, and search rows', () => {
  148. const absent = summary('absent', 4)
  149. const empty = { ...summary('empty', 3), projectionValues: { schedule: [] } }
  150. const future = {
  151. ...summary('future', 2),
  152. projectionValues: { schedule: [schedule('future', '2099-01-01T00:00:00.000Z')] },
  153. }
  154. const overdue = {
  155. ...summary('overdue', 1),
  156. projectionValues: { schedule: [schedule('overdue', '2000-01-01T00:00:00.000Z')] },
  157. }
  158. const sessions = list(absent, empty, future, overdue)
  159. const workspaces = [workspace('project', ['absent', 'empty', 'future', 'overdue'], 'Project')]
  160. const expected = [
  161. [sid('absent'), false],
  162. [sid('empty'), false],
  163. [sid('future'), true],
  164. [sid('overdue'), true],
  165. ]
  166. expect(deriveGroups(
  167. sessions, workspaces, noArchive, noAttention, view(['project']),
  168. )[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  169. expect(deriveFlat(sessions, noArchive, noAttention)
  170. .map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  171. expect(deriveSearchResults(
  172. sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10,
  173. ).items.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
  174. })
  175. it('hides subagent-origin sessions without hiding ordinary forks', () => {
  176. const parent = summary('parent', 1)
  177. const subagent = {
  178. ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const, running: true,
  179. }
  180. const grandchild = {
  181. ...summary('grandchild', 4), parentId: subagent.id, origin: 'subagent' as const, running: true,
  182. }
  183. const fork = { ...summary('fork', 2), parentId: subagent.id }
  184. const forkChild = {
  185. ...summary('fork-child', 5), parentId: fork.id, origin: 'subagent' as const, running: true,
  186. }
  187. const sessions = { ...list(parent, fork, subagent, grandchild, forkChild), current: subagent.id }
  188. const groups = deriveGroups(
  189. sessions,
  190. [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
  191. noArchive,
  192. noAttention,
  193. view(['first']),
  194. )
  195. expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id])
  196. expect(groups[0]!.sessionCount).toBe(2)
  197. expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
  198. expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
  199. expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([
  200. [fork.id, 1], [parent.id, 2],
  201. ])
  202. expect(deriveSearchResults(
  203. sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
  204. noAttention, { items: [], hasMore: false }, 10,
  205. ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
  206. })
  207. it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
  208. const parent = summary('parent', 1)
  209. const oldChild = { ...summary('old-child', 10), parentId: parent.id }
  210. const newChild = { ...summary('new-child', 20), parentId: parent.id }
  211. const tieB = { ...summary('tie-b', 20), parentId: parent.id }
  212. const tieA = { ...summary('tie-a', 20), parentId: parent.id }
  213. const self = { ...summary('self', 2), parentId: sid('self') }
  214. const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
  215. const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
  216. const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
  217. const groups = deriveGroups(
  218. list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
  219. [],
  220. noArchive,
  221. noAttention,
  222. { expandedGroups: [UNGROUPED_KEY] },
  223. )
  224. expect(groups).toHaveLength(1)
  225. expect(groups[0]!.sessions.map(node => node.id)).toEqual([
  226. newChild.id, tieA.id, tieB.id, oldChild.id,
  227. cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
  228. ])
  229. // Equal timestamps use ids as a deterministic tiebreak in either input order.
  230. expect(deriveGroups(
  231. list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]),
  232. )[0]!
  233. .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
  234. })
  235. it('tolerates Workspace membership arriving before its Session summary', () => {
  236. const partial: SessionListState = {
  237. ...list(),
  238. ids: [sid('present')],
  239. byId: { [sid('present')]: summary('present', 1) },
  240. }
  241. const groups = deriveGroups(
  242. partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']),
  243. )
  244. expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
  245. })
  246. it('hides archived sessions from workspace groups and Ungrouped', () => {
  247. const kept = summary('kept', 1, '/projects/first')
  248. const gone = summary('gone', 2, '/projects/first')
  249. const looseGone = summary('loose-gone', 3, '/other')
  250. const sessions = list(kept, gone, looseGone)
  251. const groups = deriveGroups(
  252. sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
  253. noAttention, view(['first', UNGROUPED_KEY]),
  254. )
  255. // The archived member drops from its group AND the archived stray never
  256. // surfaces an Ungrouped bucket; counts follow the visible rows.
  257. expect(groups.map(group => group.key)).toEqual(['first'])
  258. expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
  259. expect(groups[0]!.sessionCount).toBe(1)
  260. })
  261. it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
  262. const owned = summary('owned', 1)
  263. const loose = summary('loose', 2)
  264. const ws = workspace('project', ['owned'])
  265. const ownedGroups = deriveGroups(
  266. { ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(),
  267. )
  268. expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
  269. const looseGroups = deriveGroups(
  270. { ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(),
  271. )
  272. expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
  273. })
  274. })
  275. describe('deriveFlat', () => {
  276. it('flattens every session — fork children included — newest-first with id tiebreak', () => {
  277. const parent = summary('parent', 10)
  278. const child = { ...summary('child', 30), parentId: parent.id }
  279. const tieB = summary('tie-b', 20)
  280. const tieA = summary('tie-a', 20)
  281. const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention)
  282. expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
  283. })
  284. it('hides subagent-origin rows but keeps ordinary forks', () => {
  285. const parent = summary('parent', 1)
  286. const fork = { ...summary('fork', 2), parentId: parent.id }
  287. const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
  288. const rows = deriveFlat(
  289. { ...list(parent, fork, subagent), current: subagent.id },
  290. noArchive,
  291. noAttention,
  292. )
  293. expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
  294. })
  295. it('tolerates ids whose summary has not landed yet', () => {
  296. const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
  297. expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')])
  298. })
  299. it('shows only the current blank session and excludes blanks from search', () => {
  300. const currentBlank = { ...summary('current-blank', 9), blank: true }
  301. const staleBlank = { ...summary('stale-blank', 8), blank: true }
  302. const sessions = {
  303. ...list(summary('real', 1), currentBlank, staleBlank),
  304. current: currentBlank.id,
  305. }
  306. const rows = deriveFlat(sessions, noArchive, noAttention)
  307. expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
  308. expect(rows.map(row => row.title)).toEqual(['', 'real'])
  309. expect(rows.map(row => row.blank)).toEqual([true, false])
  310. })
  311. it('hides archived sessions in flat mode', () => {
  312. const kept = summary('kept', 1)
  313. const gone = summary('gone', 2)
  314. expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id])
  315. })
  316. })
  317. describe('deriveSearchResults archive filtering', () => {
  318. it('archived sessions never match — not by title and not via a backend content hit', () => {
  319. const hit = summary('hit', 2)
  320. hit.displayTitle = 'Needle row'
  321. const gone = summary('gone', 1)
  322. gone.displayTitle = 'Needle archived'
  323. const result = deriveSearchResults(
  324. list(hit, gone),
  325. [],
  326. 'needle',
  327. archived('gone'),
  328. noAttention,
  329. { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
  330. 10,
  331. )
  332. expect(result.items.map(item => item.id)).toEqual([hit.id])
  333. })
  334. })
  335. describe('deriveSearchResults', () => {
  336. it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
  337. const titleHit = summary('title-hit', 30, '/projects/a')
  338. titleHit.displayTitle = 'Needle title'
  339. const workspaceHit = summary('workspace-hit', 20, '/projects/b')
  340. workspaceHit.displayTitle = 'Ordinary title'
  341. const contentHit = summary('content-hit', 10, '/projects/c')
  342. const sessions = list(titleHit, workspaceHit, contentHit)
  343. const result = deriveSearchResults(
  344. sessions,
  345. [
  346. workspace('a', ['title-hit'], 'Alpha'),
  347. workspace('b', ['workspace-hit'], 'Needle Workspace'),
  348. workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
  349. ],
  350. ' NEEDLE ',
  351. noArchive,
  352. new Map([[titleHit.id, {
  353. key: 'question:1', kind: 'plan-review', sessionId: titleHit.id,
  354. }]]),
  355. {
  356. items: [
  357. { sessionId: contentHit.id, snippet: 'body needle excerpt' },
  358. { sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
  359. { sessionId: titleHit.id, snippet: 'title session body excerpt' },
  360. { sessionId: sid('unknown'), snippet: 'not in session.list' },
  361. ],
  362. hasMore: false,
  363. },
  364. 10,
  365. )
  366. expect(result).toEqual({
  367. items: [
  368. {
  369. id: titleHit.id,
  370. title: 'Needle title',
  371. workspace: 'Alpha',
  372. running: false,
  373. runningSubagentCount: 0,
  374. pendingInteraction: 'plan-review',
  375. completed: false,
  376. hasActiveSchedule: false,
  377. snippet: 'title session body excerpt',
  378. },
  379. {
  380. id: workspaceHit.id,
  381. title: 'Ordinary title',
  382. workspace: 'Needle Workspace',
  383. running: false,
  384. runningSubagentCount: 0,
  385. completed: false,
  386. hasActiveSchedule: false,
  387. },
  388. {
  389. id: contentHit.id,
  390. title: 'content-hit',
  391. workspace: 'c',
  392. running: false,
  393. runningSubagentCount: 0,
  394. completed: false,
  395. hasActiveSchedule: false,
  396. snippet: 'body needle excerpt',
  397. },
  398. ],
  399. hasMore: false,
  400. })
  401. })
  402. it('excludes blank sessions from search regardless of query or content hits', () => {
  403. const currentBlank = { ...summary('opaque-current', 5), blank: true }
  404. const staleBlank = { ...summary('new session stale', 4), blank: true }
  405. const sessions = {
  406. ...list(currentBlank, staleBlank),
  407. current: currentBlank.id,
  408. }
  409. // Blank placeholders never match — not their localized-display title, not
  410. // their id, and not even a backend content hit naming them.
  411. const result = deriveSearchResults(
  412. sessions,
  413. [workspace('first', ['opaque-current', 'new session stale'])],
  414. 'new session',
  415. noArchive,
  416. noAttention,
  417. {
  418. items: [
  419. { sessionId: staleBlank.id, snippet: 'stale body' },
  420. { sessionId: currentBlank.id, snippet: 'current body' },
  421. ],
  422. hasMore: false,
  423. },
  424. 10,
  425. )
  426. expect(result.items).toEqual([])
  427. })
  428. it('uses the supplied cap and preserves either local overflow or backend hasMore', () => {
  429. const rows = Array.from({ length: 5 }, (_, index) => {
  430. const item = summary(`s-${String(index).padStart(2, '0')}`, index)
  431. item.displayTitle = `Needle ${String(index)}`
  432. return item
  433. })
  434. const overflow = deriveSearchResults(
  435. list(...rows),
  436. [],
  437. 'needle',
  438. noArchive,
  439. noAttention,
  440. { items: [], hasMore: false },
  441. 3,
  442. )
  443. expect(overflow.items).toHaveLength(3)
  444. expect(overflow.hasMore).toBe(true)
  445. const backendMore = deriveSearchResults(
  446. list(summary('body', 1)),
  447. [],
  448. 'needle',
  449. noArchive,
  450. noAttention,
  451. { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
  452. 3,
  453. )
  454. expect(backendMore.items).toHaveLength(1)
  455. expect(backendMore.hasMore).toBe(true)
  456. expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3))
  457. .toEqual({ items: [], hasMore: false })
  458. })
  459. })
  460. describe('createWorkspaceViewStore', () => {
  461. it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => {
  462. const store = createWorkspaceViewStore().create()
  463. expect(store.getSnapshot().groupBy).toBe('workspace')
  464. expect(store.getSnapshot().orderBy).toBe('updated')
  465. store.actions.setGroupBy('flat')
  466. store.actions.setOrderBy('updated')
  467. store.actions.setGroupExpanded('alpha', true)
  468. store.actions.syncSessionOrderAccount('alpha', ['two', 'one'], { one: 1, two: 2 })
  469. store.actions.setSessionOrder('alpha', ['one', 'two'])
  470. expect(store.getSnapshot().groupBy).toBe('flat')
  471. expect(store.getSnapshot()).toMatchObject({
  472. orderBy: 'updated',
  473. groupExpansion: { alpha: true },
  474. sessionOrderByAccount: { alpha: ['one', 'two'] },
  475. sessionUpdatedAtByAccount: { alpha: { one: 1, two: 2 } },
  476. })
  477. })
  478. it('removes view state outside the retained Workspace key set', () => {
  479. const store = createWorkspaceViewStore().create()
  480. store.actions.setGroupExpanded('', true)
  481. store.actions.setGroupExpanded('alpha', true)
  482. store.actions.setGroupExpanded('deleted', true)
  483. store.actions.syncSessionOrderAccount('alpha', ['alpha-session'], { 'alpha-session': 2 })
  484. store.actions.syncSessionOrderAccount('deleted', ['deleted-session'], { 'deleted-session': 1 })
  485. store.actions.retainAccountKeys(['', 'alpha'])
  486. const snapshot = store.getSnapshot()
  487. expect(snapshot.groupExpansion).toEqual({ '': true, alpha: true })
  488. expect(snapshot.sessionOrderByAccount).toEqual({ alpha: ['alpha-session'] })
  489. expect(snapshot.sessionUpdatedAtByAccount).toEqual({ alpha: { 'alpha-session': 2 } })
  490. })
  491. })
  492. describe('workspaceLabel', () => {
  493. it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
  494. expect(workspaceLabel(undefined)).toBe('')
  495. expect(workspaceLabel('')).toBe('')
  496. expect(workspaceLabel('/projects/demo/')).toBe('demo')
  497. expect(workspaceLabel('C:\\projects\\demo\\')).toBe('demo')
  498. expect(workspaceLabel('/')).toBe('/')
  499. })
  500. })