import { describe, expect, it } from "vitest"; import { computeEvenSpread, getEstimateMonthRange, rebalanceSpread, summarizeMonthlySpread, } from "../estimate/monthly-spread.js"; describe("getEstimateMonthRange", () => { it("returns months between two dates in the same year", () => { expect( getEstimateMonthRange(new Date("2026-03-01"), new Date("2026-06-30")), ).toEqual(["2026-03", "2026-04", "2026-05", "2026-06"]); }); it("returns a single month when start and end are in the same month", () => { expect( getEstimateMonthRange(new Date("2026-05-10"), new Date("2026-05-20")), ).toEqual(["2026-05"]); }); it("spans across years", () => { expect( getEstimateMonthRange(new Date("2025-11-01"), new Date("2026-02-28")), ).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]); }); it("returns empty when endDate < startDate", () => { expect( getEstimateMonthRange(new Date("2026-06-01"), new Date("2026-03-01")), ).toEqual([]); }); }); describe("computeEvenSpread", () => { it("distributes hours across months weighted by working days", () => { const result = computeEvenSpread({ totalHours: 100, startDate: new Date("2026-03-01"), endDate: new Date("2026-05-31"), }); expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]); // Hours should sum to exactly 100 const sum = Object.values(result.spread).reduce((a, b) => a + b, 0); expect(Math.round(sum * 10) / 10).toBe(100); // Each month should have a positive value for (const month of result.months) { expect(result.spread[month]).toBeGreaterThan(0); } }); it("handles a single month", () => { const result = computeEvenSpread({ totalHours: 40, startDate: new Date("2026-04-01"), endDate: new Date("2026-04-30"), }); expect(result.months).toEqual(["2026-04"]); expect(result.spread["2026-04"]).toBe(40); }); it("handles partial first and last months", () => { const result = computeEvenSpread({ totalHours: 80, startDate: new Date("2026-03-15"), endDate: new Date("2026-04-15"), }); expect(result.months).toEqual(["2026-03", "2026-04"]); const sum = Object.values(result.spread).reduce((a, b) => a + b, 0); expect(Math.round(sum * 10) / 10).toBe(80); // March 15-31 has fewer working days than April 1-15 // Both should be positive expect(result.spread["2026-03"]).toBeGreaterThan(0); expect(result.spread["2026-04"]).toBeGreaterThan(0); }); it("returns empty spread for empty date range", () => { const result = computeEvenSpread({ totalHours: 100, startDate: new Date("2026-06-01"), endDate: new Date("2026-03-01"), }); expect(result.months).toEqual([]); expect(result.spread).toEqual({}); }); it("handles zero total hours", () => { const result = computeEvenSpread({ totalHours: 0, startDate: new Date("2026-03-01"), endDate: new Date("2026-05-31"), }); expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]); for (const month of result.months) { expect(result.spread[month]).toBe(0); } }); }); describe("rebalanceSpread", () => { it("distributes remaining hours after locked months", () => { const result = rebalanceSpread({ totalHours: 100, startDate: new Date("2026-03-01"), endDate: new Date("2026-05-31"), lockedMonths: { "2026-03": 50 }, }); expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]); expect(result.spread["2026-03"]).toBe(50); // Remaining 50 hours across April + May const remaining = (result.spread["2026-04"] ?? 0) + (result.spread["2026-05"] ?? 0); expect(Math.round(remaining * 10) / 10).toBe(50); }); it("handles all months locked", () => { const result = rebalanceSpread({ totalHours: 100, startDate: new Date("2026-03-01"), endDate: new Date("2026-04-30"), lockedMonths: { "2026-03": 60, "2026-04": 40 }, }); expect(result.spread["2026-03"]).toBe(60); expect(result.spread["2026-04"]).toBe(40); }); it("clamps remaining to zero when locked exceeds total", () => { const result = rebalanceSpread({ totalHours: 50, startDate: new Date("2026-03-01"), endDate: new Date("2026-04-30"), lockedMonths: { "2026-03": 60 }, }); expect(result.spread["2026-03"]).toBe(60); // Remaining is max(0, 50-60) = 0 expect(result.spread["2026-04"]).toBe(0); }); it("ignores locked months outside the date range", () => { const result = rebalanceSpread({ totalHours: 80, startDate: new Date("2026-03-01"), endDate: new Date("2026-04-30"), lockedMonths: { "2026-01": 30, "2026-03": 40 }, }); // 2026-01 is outside range, so remaining = 80 - 40 = 40 expect(result.spread["2026-03"]).toBe(40); expect(result.spread["2026-04"]).toBe(40); }); }); describe("summarizeMonthlySpread", () => { it("aggregates hours per month across multiple spreads", () => { const result = summarizeMonthlySpread([ { "2026-03": 30, "2026-04": 20 }, { "2026-03": 10, "2026-04": 15, "2026-05": 25 }, ]); expect(result).toEqual({ "2026-03": 40, "2026-04": 35, "2026-05": 25, }); }); it("returns empty for empty input", () => { expect(summarizeMonthlySpread([])).toEqual({}); }); });