import { describe, expect, test } from "bun:test"; import { summarizeActivity } from "./time-ranges"; // summarizeActivity is the shared activity view model behind the agent // capabilities (getRecentActivity / getProjectActivity). The contract that // matters: total time uses per-project gap-merge then sum (same billable // clock as invoices / timeline stats), NOT a naive sum of per-event minutes. const at = (iso: string) => new Date(iso); describe("summarizeActivity", () => { test("no events → zero total, empty breakdown", () => { const s = summarizeActivity([]); expect(s.totalMinutes).toBe(0); expect(s.bySource).toEqual([]); }); test("single event → one 15-minute gap window", () => { const s = summarizeActivity([ { id: "a", source: "git", timestamp: at("2026-01-01T10:00:00Z") }, ]); expect(s.totalMinutes).toBe(15); expect(s.bySource).toEqual([{ source: "git", events: 1, minutes: 15 }]); }); test("cross-source overlap within one project is counted once in the total", () => { // git at 10:00 and claude at 10:05 same project → 10:00→10:20 = 20 min. const s = summarizeActivity([ { id: "g", source: "git", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", }, { id: "c", source: "claude", timestamp: at("2026-01-01T10:05:00Z"), projectId: "A", }, ]); expect(s.totalMinutes).toBe(20); // Per-source windows are each computed independently, so they can sum to // more than the project total — that within-project overlap is collapsed. const bySource = Object.fromEntries(s.bySource.map((b) => [b.source, b])); expect(bySource.git).toEqual({ source: "git", events: 1, minutes: 15 }); expect(bySource.claude).toEqual({ source: "claude", events: 1, minutes: 15, }); const perSourceSum = s.bySource.reduce((n, b) => n + b.minutes, 0); expect(perSourceSum).toBeGreaterThan(s.totalMinutes); }); test("concurrent multi-project work sums fully in totalMinutes", () => { const s = summarizeActivity([ { id: "a", source: "claude", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", }, { id: "b", source: "claude", timestamp: at("2026-01-01T10:05:00Z"), projectId: "B", }, ]); // A 15 + B 15 = 30 (not unique wall-clock 20) expect(s.totalMinutes).toBe(30); }); test("events separated by more than the gap threshold are distinct windows", () => { const s = summarizeActivity([ { id: "a", source: "git", timestamp: at("2026-01-01T10:00:00Z") }, { id: "b", source: "git", timestamp: at("2026-01-01T11:00:00Z") }, ]); expect(s.totalMinutes).toBe(30); // two separate 15-min windows expect(s.bySource).toEqual([{ source: "git", events: 2, minutes: 30 }]); }); test("does not depend on estimatedMinutes — pure timestamp/window math", () => { // Three rapid-fire events in one minute collapse to a single window — // 09:00:00 to 09:01:00 + 15-min tail = 16 min. A naive per-event sum of // estimated minutes (3 × 15 = 45) would over-count badly here. const s = summarizeActivity([ { id: "1", source: "file", timestamp: at("2026-01-01T09:00:00Z") }, { id: "2", source: "file", timestamp: at("2026-01-01T09:00:30Z") }, { id: "3", source: "file", timestamp: at("2026-01-01T09:01:00Z") }, ]); expect(s.totalMinutes).toBe(16); expect(s.bySource).toEqual([{ source: "file", events: 3, minutes: 16 }]); }); test("coerces epoch-number and ISO-string timestamps", () => { const base = at("2026-01-01T10:00:00Z").getTime(); const s = summarizeActivity([ { id: "n", source: "git", timestamp: base }, { id: "s", source: "git", timestamp: "2026-01-01T10:05:00Z" }, ]); expect(s.totalMinutes).toBe(20); }); });