248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import type { SessionInfo } from '@/types/hermes'
|
|
|
|
import { makeSessionInfo } from '../test/session-info'
|
|
|
|
import type { SidebarSessionEntry } from './session-branch-tree'
|
|
import { groupEntriesByRecency, hideCollapsedGroupRows, toSessionRows } from './session-date-groups'
|
|
|
|
const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo =>
|
|
makeSessionInfo({ id, message_count: 1, source: 'cli', title: id, ...overrides })
|
|
|
|
const entry = (s: SessionInfo, branchStem?: string): SidebarSessionEntry =>
|
|
branchStem ? { branchStem, session: s } : { session: s }
|
|
|
|
// Fixed "now": Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday).
|
|
// All tests pin a Monday week start so calendar boundaries are deterministic.
|
|
const NOW = new Date(2026, 5, 18, 12, 0, 0).getTime()
|
|
const MONDAY = 1
|
|
|
|
const at = (year: number, month: number, day: number, hour = 10, minute = 0): number =>
|
|
Math.floor(new Date(year, month, day, hour, minute, 0).getTime() / 1000)
|
|
|
|
const group = (entries: SidebarSessionEntry[], nowMs = NOW) => groupEntriesByRecency(entries, nowMs, MONDAY)
|
|
|
|
const dividerKeys = (rows: ReturnType<typeof groupEntriesByRecency>): string[] =>
|
|
rows.flatMap(row => (row.kind === 'divider' ? [row.key] : []))
|
|
|
|
describe('groupEntriesByRecency', () => {
|
|
it('cuts the head after the most recent handful, then divides by coarse ranges', () => {
|
|
// The morning run (30m/30m/4h/30m gaps, then a 14h silence) is the
|
|
// unlabelled head; each older group gets one divider, coarsening with age.
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
|
|
entry(session('b', { last_active: at(2026, 5, 18, 10, 30) })),
|
|
entry(session('c', { last_active: at(2026, 5, 18, 10) })),
|
|
entry(session('d', { last_active: at(2026, 5, 18, 6) })),
|
|
entry(session('e', { last_active: at(2026, 5, 18, 5, 30) })),
|
|
entry(session('f', { last_active: at(2026, 5, 17, 15) })), // yesterday
|
|
entry(session('g', { last_active: at(2026, 5, 16, 15) })), // Tue this week
|
|
entry(session('h', { last_active: at(2026, 5, 14) })), // Sun last week
|
|
entry(session('i', { last_active: at(2026, 5, 3) })), // earlier in June
|
|
entry(session('j', { last_active: at(2026, 4, 28) })), // May
|
|
entry(session('k', { last_active: at(2025, 11, 3) })) // December 2025
|
|
])
|
|
|
|
expect(rows.slice(0, 5).every(row => row.kind === 'session')).toBe(true)
|
|
expect(rows[5]).toMatchObject({ key: 'yesterday', kind: 'divider' })
|
|
expect(dividerKeys(rows)).toEqual(['yesterday', 'this-week', 'last-week', 'this-month', 'm-2026-4', 'my-2025-11'])
|
|
})
|
|
|
|
it('labels the rest of the current day "earlier today" past a real break', () => {
|
|
// Five rapid-fire sessions, a ~4h pause, then more of the same day.
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
|
|
entry(session('b', { last_active: at(2026, 5, 18, 10, 58) })),
|
|
entry(session('c', { last_active: at(2026, 5, 18, 10, 56) })),
|
|
entry(session('d', { last_active: at(2026, 5, 18, 10, 54) })),
|
|
entry(session('e', { last_active: at(2026, 5, 18, 10, 52) })),
|
|
entry(session('f', { last_active: at(2026, 5, 18, 7) })),
|
|
entry(session('g', { last_active: at(2026, 5, 18, 6, 58) }))
|
|
])
|
|
|
|
expect(rows.findIndex(row => row.kind === 'divider')).toBe(5)
|
|
expect(dividerKeys(rows)).toEqual(['today'])
|
|
})
|
|
|
|
it('never slices a rapid-fire burst mid-run', () => {
|
|
// Eleven sessions two minutes apart: no gap qualifies as a break, so the
|
|
// whole burst stays in the head and the divider lands after it.
|
|
const burst = Array.from({ length: 11 }, (_, i) =>
|
|
entry(session(`s${i}`, { last_active: at(2026, 5, 18, 11) - i * 120 }))
|
|
)
|
|
|
|
const rows = group([...burst, entry(session('old', { last_active: at(2026, 5, 17, 15) }))])
|
|
|
|
expect(rows.findIndex(row => row.kind === 'divider')).toBe(11)
|
|
expect(dividerKeys(rows)).toEqual(['yesterday'])
|
|
})
|
|
|
|
it('chains the head run across midnight', () => {
|
|
// Viewed at 00:58: tonight plus last evening is one run; yesterday's
|
|
// afternoon (a different nominal day) opens the labelled groups.
|
|
const smallHours = new Date(2026, 5, 19, 0, 58).getTime()
|
|
|
|
const rows = group(
|
|
[
|
|
entry(session('a', { last_active: at(2026, 5, 19, 0, 30) })),
|
|
entry(session('b', { last_active: at(2026, 5, 18, 23, 50) })),
|
|
entry(session('c', { last_active: at(2026, 5, 18, 23, 20) })),
|
|
entry(session('d', { last_active: at(2026, 5, 17, 20) }))
|
|
],
|
|
smallHours
|
|
)
|
|
|
|
expect(rows.findIndex(row => row.kind === 'divider')).toBe(3)
|
|
expect(dividerKeys(rows)).toEqual(['yesterday'])
|
|
})
|
|
|
|
it('dissolves a stale head into its own calendar group (fuzzy merge)', () => {
|
|
// Newest session is 6 days old and the rows below it share its "last week"
|
|
// bucket: cutting there would strand near-identical neighbours around a
|
|
// divider, so no head is kept and the whole bucket leads unlabelled.
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 12) })),
|
|
entry(session('b', { last_active: at(2026, 5, 11, 15) })),
|
|
entry(session('c', { last_active: at(2026, 5, 11, 10) })),
|
|
entry(session('d', { last_active: at(2026, 5, 3) })),
|
|
entry(session('e', { last_active: at(2026, 4, 20) }))
|
|
])
|
|
|
|
expect(rows.slice(0, 3).every(row => row.kind === 'session')).toBe(true)
|
|
expect(dividerKeys(rows)).toEqual(['this-month', 'm-2026-4'])
|
|
})
|
|
|
|
it('keeps an isolated newest session as the head when its bucket differs', () => {
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 18, 9) })),
|
|
entry(session('b', { last_active: at(2026, 5, 17, 15) })),
|
|
entry(session('c', { last_active: at(2026, 5, 3) }))
|
|
])
|
|
|
|
expect(rows.findIndex(row => row.kind === 'divider')).toBe(1)
|
|
expect(dividerKeys(rows)).toEqual(['yesterday', 'this-month'])
|
|
})
|
|
|
|
it('emits no dividers when everything is one unbroken run', () => {
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
|
|
entry(session('b', { last_active: at(2026, 5, 18, 10, 50) })),
|
|
entry(session('c', { last_active: at(2026, 5, 18, 10, 40) }))
|
|
])
|
|
|
|
expect(rows.every(row => row.kind === 'session')).toBe(true)
|
|
})
|
|
|
|
it('collapses a big gap straight to the next month/year (empty ranges omitted)', () => {
|
|
const rows = group([
|
|
entry(session('t', { last_active: at(2026, 5, 18, 11) })),
|
|
entry(session('t2', { last_active: at(2026, 5, 18, 10, 30) })),
|
|
entry(session('j1', { last_active: at(2026, 0, 5) })),
|
|
entry(session('j2', { last_active: at(2026, 0, 3) })),
|
|
entry(session('old', { last_active: at(2024, 2, 9) }))
|
|
])
|
|
|
|
expect(dividerKeys(rows)).toEqual(['m-2026-0', 'my-2024-2'])
|
|
})
|
|
|
|
it('never labels the first rendered group, even when it is not recent', () => {
|
|
// Newest session is weeks old and alone in its month: it opens the list
|
|
// unlabelled; only the transitions below it are marked.
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 4, 20) })),
|
|
entry(session('b', { last_active: at(2026, 2, 3) })),
|
|
entry(session('c', { last_active: at(2025, 11, 3) }))
|
|
])
|
|
|
|
expect(rows[0]).toMatchObject({ kind: 'session' })
|
|
expect(dividerKeys(rows)).toEqual(['m-2026-2', 'my-2025-11'])
|
|
})
|
|
|
|
it('keeps branch children in their parent cluster without opening a new bucket', () => {
|
|
const parent = session('parent', { last_active: at(2026, 5, 18, 11) })
|
|
const child = session('child', { last_active: at(2024, 0, 1), parent_session_id: 'parent' })
|
|
|
|
const rows = group([entry(parent), entry(child, '└─ ')])
|
|
|
|
expect(rows).toEqual([
|
|
{ entry: entry(parent), kind: 'session' },
|
|
{ entry: entry(child, '└─ '), kind: 'session' }
|
|
])
|
|
})
|
|
|
|
it('never emits a divider twice under a non-monotonic order', () => {
|
|
const rows = group([
|
|
entry(session('a', { last_active: at(2026, 5, 16) })), // head run
|
|
entry(session('b', { last_active: at(2026, 4, 5) })), // May — divider
|
|
entry(session('c', { last_active: at(2026, 5, 16) })) // head again — no repeat
|
|
])
|
|
|
|
expect(dividerKeys(rows)).toEqual(['m-2026-4'])
|
|
})
|
|
|
|
it('falls back to started_at when last_active is missing', () => {
|
|
const rows = group([
|
|
entry(session('head', { last_active: at(2026, 5, 18, 11) })),
|
|
entry(session('s', { last_active: 0, started_at: at(2026, 5, 10) }))
|
|
])
|
|
|
|
expect(dividerKeys(rows)).toEqual(['last-week'])
|
|
})
|
|
})
|
|
|
|
describe('hideCollapsedGroupRows', () => {
|
|
it('returns the same array when every group is open', () => {
|
|
const rows = [
|
|
{ entry: entry(session('a')), kind: 'session' as const },
|
|
{ key: 'yesterday', kind: 'divider' as const, label: 'Yesterday' },
|
|
{ entry: entry(session('b')), kind: 'session' as const }
|
|
]
|
|
|
|
expect(hideCollapsedGroupRows(rows, () => true)).toBe(rows)
|
|
})
|
|
|
|
it('keeps the divider and drops sessions under a closed group', () => {
|
|
const rows = [
|
|
{ entry: entry(session('head')), kind: 'session' as const },
|
|
{ key: 'yesterday', kind: 'divider' as const, label: 'Yesterday' },
|
|
{ entry: entry(session('y1')), kind: 'session' as const },
|
|
{ entry: entry(session('y2'), '└─ '), kind: 'session' as const },
|
|
{ key: 'last-week', kind: 'divider' as const, label: 'Last week' },
|
|
{ entry: entry(session('lw')), kind: 'session' as const }
|
|
]
|
|
|
|
const visible = hideCollapsedGroupRows(rows, key => key !== 'yesterday')
|
|
|
|
expect(visible).toEqual([rows[0], rows[1], rows[4], rows[5]])
|
|
})
|
|
|
|
it('never hides the unlabelled head above the first divider', () => {
|
|
const rows = [
|
|
{ entry: entry(session('a')), kind: 'session' as const },
|
|
{ entry: entry(session('b')), kind: 'session' as const },
|
|
{ key: 'yesterday', kind: 'divider' as const, label: 'Yesterday' },
|
|
{ entry: entry(session('c')), kind: 'session' as const }
|
|
]
|
|
|
|
const visible = hideCollapsedGroupRows(rows, () => false)
|
|
|
|
expect(visible.map(row => (row.kind === 'session' ? row.entry.session.id : row.key))).toEqual([
|
|
'a',
|
|
'b',
|
|
'yesterday'
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('toSessionRows', () => {
|
|
it('wraps entries as session rows with no dividers', () => {
|
|
const entries = [entry(session('a')), entry(session('b'), '└─ ')]
|
|
|
|
expect(toSessionRows(entries)).toEqual([
|
|
{ entry: entries[0], kind: 'session' },
|
|
{ entry: entries[1], kind: 'session' }
|
|
])
|
|
})
|
|
})
|