import { describe, expect, test } from "bun:test"; import type { TimelineEventDisplay } from "../types/timeline"; import { buildActivityReport } from "./activity-report"; import { type StreamBillableEvent, type StreamReportCursor, streamBillableReport, } from "./stream-billable-report"; import { calculateBillableMinutes } from "./time-ranges"; function event( id: string, timestamp: string, projectId: string, source: TimelineEventDisplay["source"] = "git", overrides: Partial = {}, ): StreamBillableEvent { return { projectKey: projectId, id, timestamp, source, sourceId: null, projectRootId: `root-${projectId}`, projectId, metadata: source === "git" ? { message: `Ship complete report work for ${id}`, author: "tester", filesChanged: ["report.ts"], additions: 1, deletions: 0, } : null, estimatedMinutes: null, projectName: `Project ${projectId}`, projectColor: null, clientId: null, deviceId: "device-1", deviceName: "Laptop", ...overrides, }; } function compareCursor( row: StreamBillableEvent, cursor: StreamReportCursor, ): number { if (row.projectKey !== cursor.projectKey) { return row.projectKey.localeCompare(cursor.projectKey); } const timestamp = new Date(row.timestamp).getTime(); if (timestamp !== cursor.timestamp.getTime()) { return timestamp - cursor.timestamp.getTime(); } return row.id.localeCompare(cursor.id); } function loader(events: StreamBillableEvent[]) { const sorted = [...events].sort((a, b) => { if (a.projectKey !== b.projectKey) { return a.projectKey.localeCompare(b.projectKey); } const timestamp = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); return timestamp || a.id.localeCompare(b.id); }); return async (cursor: StreamReportCursor | null, chunkSize: number) => { const start = cursor ? sorted.findIndex((row) => compareCursor(row, cursor) > 0) : 0; if (start < 0) return []; return sorted.slice(start, start + chunkSize); }; } describe("streamBillableReport", () => { test("matches the in-memory ActivityReport and billable totals", async () => { const events = [ event("a1", "2026-01-01T10:00:00Z", "a"), event("a2", "2026-01-01T10:10:00Z", "a"), event("b1", "2026-01-02T12:00:00Z", "b"), event("manual", "2026-01-02T13:00:00Z", "b", "manual", { metadata: { title: "Facilitated customer planning session" }, estimatedMinutes: 45, }), ]; const options = { start: "2026-01-01T00:00:00Z", end: "2026-01-02T23:59:59Z", includeClientBreakdown: true, chunkSize: 2, }; const streamed = await streamBillableReport(loader(events), options); const inMemory = buildActivityReport(events, options); expect(streamed).toEqual(inMemory); expect(streamed.totalEstimatedMinutes).toBe( calculateBillableMinutes(events), ); }); test("counts concurrent projects independently", async () => { const events = [ event("a1", "2026-01-01T10:00:00Z", "a"), event("a2", "2026-01-01T10:10:00Z", "a"), event("b1", "2026-01-01T10:05:00Z", "b"), ]; const report = await streamBillableReport(loader(events), { chunkSize: 2, }); expect( report.projects.find((row) => row.projectId === "a")?.estimatedMinutes, ).toBe(25); expect( report.projects.find((row) => row.projectId === "b")?.estimatedMinutes, ).toBe(15); expect(report.totalEstimatedMinutes).toBe(40); }); test("collapses 100 dense chat events into one padded session", async () => { const start = Date.parse("2026-01-01T10:00:00Z"); const events = Array.from({ length: 100 }, (_, index) => event( `chat-${String(index).padStart(3, "0")}`, new Date(start + index * 6_000).toISOString(), "chat", "claude", { metadata: { sessionId: "session-1", messagePreview: "Working through the complete long horizon report", conversationFile: "/tmp/session.jsonl", }, }, ), ); const report = await streamBillableReport(loader(events), { chunkSize: 7, }); expect(report.totalEvents).toBe(100); expect(report.totalEstimatedMinutes).toBe(25); expect(report.totalEstimatedMinutes).not.toBe(1_500); }); test("keeps a gap-merge session continuous across page boundaries", async () => { const events = Array.from({ length: 5 }, (_, index) => event( `event-${index}`, new Date( Date.parse("2026-01-01T10:00:00Z") + index * 5 * 60_000, ).toISOString(), "a", ), ); const report = await streamBillableReport(loader(events), { chunkSize: 3, }); expect(report.totalEvents).toBe(5); expect(report.totalEstimatedMinutes).toBe(35); }); test("processes more than the former 50k hosted cap without dropping rows", async () => { const eventCount = 50_001; const start = Date.parse("2024-01-01T00:00:00Z"); const events = Array.from({ length: eventCount }, (_, index) => event( `event-${String(index).padStart(5, "0")}`, new Date(start + index * 60_000).toISOString(), "a", ), ); const report = await streamBillableReport(loader(events), { chunkSize: 137, }); expect(report.totalEvents).toBe(eventCount); expect(report.projects[0]?.eventCount).toBe(eventCount); expect(report.totalEstimatedMinutes).toBe(eventCount - 1 + 15); }); });