178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
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({});
|
|
});
|
|
});
|