/** * test/http/digest.test.ts (W3 quick-wins c) — buildDigest read-side aggregate. * * Pure over an injected LiveSessionInfo[] (mirrors buildProjects injection): * counts finished (idle & lastOutputAt > since), needsInput (waiting), stuck, * working; sums telemetry.costUsd; empty → zeroes; future `since` → 0 finished; * bad `since` clamps to 0. */ import { describe, it, expect } from 'vitest' import type { LiveSessionInfo, ClaudeStatus } from '../../src/types.js' import { buildDigest, clampSince } from '../../src/http/digest.js' function live(over: Partial & { id: string; status: ClaudeStatus }): LiveSessionInfo { return { createdAt: 1000, clientCount: 0, exited: false, cwd: null, cols: 80, rows: 24, ...over, } } // ── clampSince ──────────────────────────────────────────────────────────────── describe('clampSince', () => { it('passes through a finite non-negative number', () => { expect(clampSince(1234)).toBe(1234) expect(clampSince(0)).toBe(0) }) it('clamps NaN / negative / non-numeric / undefined to 0', () => { expect(clampSince(NaN)).toBe(0) expect(clampSince(-5)).toBe(0) expect(clampSince('abc')).toBe(0) expect(clampSince(undefined)).toBe(0) }) it('parses a numeric string', () => { expect(clampSince('42')).toBe(42) }) }) // ── buildDigest ─────────────────────────────────────────────────────────────── describe('buildDigest', () => { it('returns an all-zero aggregate for an empty list', () => { const d = buildDigest([], 0) expect(d).toMatchObject({ since: 0, total: 0, finished: 0, needsInput: 0, stuck: 0, working: 0, totalCostUsd: 0, sessions: [], }) expect(typeof d.generatedAt).toBe('number') }) it('counts finished (idle + output after since), needsInput, stuck, working', () => { const sessions: LiveSessionInfo[] = [ live({ id: 'a', status: 'idle', lastOutputAt: 5000 }), // finished (5000 > 100) live({ id: 'b', status: 'waiting' }), // needsInput live({ id: 'c', status: 'stuck' }), // stuck live({ id: 'd', status: 'working' }), // working live({ id: 'e', status: 'idle', lastOutputAt: 50 }), // idle but stale (50 < 100) → not finished ] const d = buildDigest(sessions, 100) expect(d.total).toBe(5) expect(d.finished).toBe(1) expect(d.needsInput).toBe(1) expect(d.stuck).toBe(1) expect(d.working).toBe(1) }) it('does not count an idle session with no lastOutputAt as finished', () => { const d = buildDigest([live({ id: 'a', status: 'idle' })], 0) expect(d.finished).toBe(0) }) it('treats a future `since` as "nothing new" (0 finished)', () => { const d = buildDigest([live({ id: 'a', status: 'idle', lastOutputAt: 1000 })], 999_999_999_999) expect(d.finished).toBe(0) }) it('sums telemetry.costUsd across sessions', () => { const sessions: LiveSessionInfo[] = [ live({ id: 'a', status: 'idle', telemetry: { at: 1, costUsd: 1.5 } }), live({ id: 'b', status: 'working', telemetry: { at: 1, costUsd: 2.25 } }), live({ id: 'c', status: 'working' }), // no telemetry → contributes 0 ] const d = buildDigest(sessions, 0) expect(d.totalCostUsd).toBeCloseTo(3.75) }) it('projects per-session flags + title (last cwd segment)', () => { const d = buildDigest([live({ id: 'a', status: 'waiting', cwd: '/home/u/my-repo' })], 0) const row = d.sessions[0] expect(row?.id).toBe('a') expect(row?.title).toBe('my-repo') expect(row?.needsInput).toBe(true) expect(row?.finished).toBe(false) expect(row?.stuck).toBe(false) }) it('clamps a bad `since` to 0', () => { const d = buildDigest([], NaN as unknown as number) expect(d.since).toBe(0) }) })