import { describe, it, expect } from "vitest"; import { computeProjectMetrics, type MetricCommit } from "@/lib/metrics/compute"; import { DEFAULT_SESSION_CONSTANTS } from "@/lib/metrics/sessions"; function commit(overrides: Partial): MetricCommit { return { type: "feat", scope: "stock", authoredAt: "2026-09-01T10:00:00Z", isMerge: false, ...overrides, }; } describe("computeProjectMetrics", () => { it("is empty and safe for a project with no commits", () => { const m = computeProjectMetrics([], DEFAULT_SESSION_CONSTANTS); expect(m.totalCommits).toBe(0); expect(m.features).toBe(0); expect(m.activeDays).toBe(0); expect(m.workdays).toBe(0); expect(m.estimatedMinutes).toBe(0); expect(m.firstCommit).toBe(""); }); it("excludes merges from every count", () => { const m = computeProjectMetrics( [commit({}), commit({ isMerge: true, type: "merge" })], DEFAULT_SESSION_CONSTANTS ); expect(m.totalCommits).toBe(1); expect(m.mergeCommits).toBe(1); expect(m.byType.find((t) => t.key === "merge")).toBeUndefined(); }); it("counts features and fixes", () => { const m = computeProjectMetrics( [commit({ type: "feat" }), commit({ type: "feat" }), commit({ type: "fix" })], DEFAULT_SESSION_CONSTANTS ); expect(m.features).toBe(2); expect(m.fixes).toBe(1); }); it("ranks types and scopes by volume", () => { const m = computeProjectMetrics( [ commit({ type: "feat", scope: "stock" }), commit({ type: "feat", scope: "stock" }), commit({ type: "fix", scope: "quotes" }), ], DEFAULT_SESSION_CONSTANTS ); expect(m.byType[0]).toEqual({ key: "feat", count: 2 }); expect(m.byScope[0]).toEqual({ key: "stock", count: 2 }); }); it("omits commits with no scope from the scope breakdown", () => { // An empty bar labelled "" tells the reader nothing and takes the place of // a real feature area. const m = computeProjectMetrics( [commit({ scope: "" }), commit({ scope: "stock" })], DEFAULT_SESSION_CONSTANTS ); expect(m.byScope).toEqual([{ key: "stock", count: 1 }]); }); it("counts distinct active days, not commits", () => { const m = computeProjectMetrics( [ commit({ authoredAt: "2026-09-01T10:00:00Z" }), commit({ authoredAt: "2026-09-01T16:00:00Z" }), commit({ authoredAt: "2026-09-03T10:00:00Z" }), ], DEFAULT_SESSION_CONSTANTS ); expect(m.activeDays).toBe(2); expect(m.totalCommits).toBe(3); }); it("spans from the first commit to the last", () => { const m = computeProjectMetrics( [ commit({ authoredAt: "2026-09-03T10:00:00Z" }), commit({ authoredAt: "2026-05-24T10:00:00Z" }), ], DEFAULT_SESSION_CONSTANTS ); expect(m.firstCommit).toBe("2026-05-24"); expect(m.lastCommit).toBe("2026-09-03"); }); it("counts workdays across the span, not calendar days", () => { const m = computeProjectMetrics( [ commit({ authoredAt: "2026-05-24T10:00:00Z" }), commit({ authoredAt: "2026-09-08T10:00:00Z" }), ], DEFAULT_SESSION_CONSTANTS ); // The same window as the Hermes build log. expect(m.calendarDays).toBe(108); expect(m.workdays).toBe(75); }); it("builds a per-day activity map", () => { const m = computeProjectMetrics( [ commit({ authoredAt: "2026-09-01T10:00:00Z" }), commit({ authoredAt: "2026-09-01T16:00:00Z" }), commit({ authoredAt: "2026-09-03T10:00:00Z" }), ], DEFAULT_SESSION_CONSTANTS ); expect(m.activity["2026-09-01"]).toBe(2); expect(m.activity["2026-09-03"]).toBe(1); expect(m.activity["2026-09-02"]).toBeUndefined(); }); it("groups estimated minutes by month", () => { const m = computeProjectMetrics( [ commit({ authoredAt: "2026-08-01T10:00:00Z" }), commit({ authoredAt: "2026-09-01T10:00:00Z" }), ], DEFAULT_SESSION_CONSTANTS ); const months = m.monthly.map((x) => x.month); expect(months).toEqual(["2026-08", "2026-09"]); expect(m.monthly.every((x) => x.minutes > 0)).toBe(true); }); it("records the constants it used", () => { // A figure on screen must be explicable by the settings that produced it, // not by whatever the settings happen to be when someone later asks. const custom = { sessionGapMinutes: 90, preSessionMinutes: 10, postSessionMinutes: 5 }; const m = computeProjectMetrics([commit({})], custom); expect(m.constants).toEqual(custom); expect(m.estimatedMinutes).toBe(15); }); });