tree.client.spec.ts 21 KB

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