import { describe, expect, it } from "vitest"; import { deriveResourceForecast, calculateGroupChargeability, calculateGroupTarget, sumFte, getMonthRange, getMonthKeys, countWorkingDaysInOverlap, } from "../chargeability/calculator.js"; // ─── deriveResourceForecast ────────────────────────────────────────────────── describe("deriveResourceForecast", () => { it("returns unassigned=1 when sah=0", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [], sah: 0, }); expect(result).toEqual({ chg: 0, bd: 0, mdi: 0, mo: 0, pdr: 0, absence: 0, unassigned: 1 }); }); it("fully assigned to chargeable work", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "Chg" }], sah: 160, // 8h * 20d }); expect(result.chg).toBeCloseTo(1, 5); expect(result.unassigned).toBeCloseTo(0, 5); }); it("50% chargeable, 25% BD, rest unassigned", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [ { hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" }, { hoursPerDay: 2, workingDays: 20, categoryCode: "BD" }, ], sah: 160, }); expect(result.chg).toBeCloseTo(0.5, 5); expect(result.bd).toBeCloseTo(0.25, 5); expect(result.unassigned).toBeCloseTo(0.25, 5); }); it("handles case-insensitive category codes", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [ { hoursPerDay: 8, workingDays: 10, categoryCode: "chg" }, { hoursPerDay: 8, workingDays: 10, categoryCode: "CHG" }, ], sah: 160, }); expect(result.chg).toBeCloseTo(1, 5); }); it("handles MD&I and M&O codes with ampersand", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [ { hoursPerDay: 4, workingDays: 20, categoryCode: "MD&I" }, { hoursPerDay: 4, workingDays: 20, categoryCode: "M&O" }, ], sah: 160, }); expect(result.mdi).toBeCloseTo(0.5, 5); expect(result.mo).toBeCloseTo(0.5, 5); }); it("handles PD&R code", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [{ hoursPerDay: 8, workingDays: 20, categoryCode: "PD&R" }], sah: 160, }); expect(result.pdr).toBeCloseTo(1, 5); }); it("handles absence category", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [ { hoursPerDay: 4, workingDays: 20, categoryCode: "Chg" }, { hoursPerDay: 4, workingDays: 20, categoryCode: "Absence" }, ], sah: 160, }); expect(result.chg).toBeCloseTo(0.5, 5); expect(result.absence).toBeCloseTo(0.5, 5); expect(result.unassigned).toBeCloseTo(0, 5); }); it("clamps ratios at 1 when over-assigned", () => { const result = deriveResourceForecast({ fte: 1, targetPercentage: 0.8, assignments: [{ hoursPerDay: 16, workingDays: 20, categoryCode: "Chg" }], sah: 160, }); expect(result.chg).toBe(1); }); it("multiple assignments same category are summed", () => { const result = deriveResourceForecast({ fte: 0.5, targetPercentage: 0.8, assignments: [ { hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" }, { hoursPerDay: 2, workingDays: 20, categoryCode: "Chg" }, ], sah: 80, }); expect(result.chg).toBeCloseTo(1, 5); }); }); // ─── calculateGroupChargeability ───────────────────────────────────────────── describe("calculateGroupChargeability", () => { it("returns 0 for empty group", () => { expect(calculateGroupChargeability([])).toBe(0); }); it("single resource returns its chargeability", () => { expect(calculateGroupChargeability([{ fte: 1, chargeability: 0.75 }])).toBeCloseTo(0.75, 5); }); it("FTE-weighted average of two resources", () => { const result = calculateGroupChargeability([ { fte: 1, chargeability: 0.8 }, { fte: 0.5, chargeability: 0.6 }, ]); // (1*0.8 + 0.5*0.6) / (1 + 0.5) = 1.1 / 1.5 = 0.7333... expect(result).toBeCloseTo(0.7333, 3); }); it("returns 0 when all FTEs are 0", () => { expect(calculateGroupChargeability([{ fte: 0, chargeability: 0.9 }])).toBe(0); }); }); // ─── calculateGroupTarget ──────────────────────────────────────────────────── describe("calculateGroupTarget", () => { it("returns 0 for empty group", () => { expect(calculateGroupTarget([])).toBe(0); }); it("FTE-weighted target average", () => { const result = calculateGroupTarget([ { fte: 1, targetPercentage: 0.8 }, { fte: 1, targetPercentage: 0.6 }, ]); expect(result).toBeCloseTo(0.7, 5); }); }); // ─── sumFte ────────────────────────────────────────────────────────────────── describe("sumFte", () => { it("sums FTE values", () => { expect(sumFte([{ fte: 1 }, { fte: 0.5 }, { fte: 0.75 }])).toBeCloseTo(2.25, 5); }); it("returns 0 for empty array", () => { expect(sumFte([])).toBe(0); }); }); // ─── getMonthRange ─────────────────────────────────────────────────────────── describe("getMonthRange", () => { it("January 2026", () => { const { start, end } = getMonthRange(2026, 1); expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); expect(end.toISOString()).toBe("2026-01-31T00:00:00.000Z"); }); it("February 2024 (leap year)", () => { const { start, end } = getMonthRange(2024, 2); expect(start.toISOString()).toBe("2024-02-01T00:00:00.000Z"); expect(end.toISOString()).toBe("2024-02-29T00:00:00.000Z"); }); it("February 2025 (non-leap year)", () => { const { start, end } = getMonthRange(2025, 2); expect(end.toISOString()).toBe("2025-02-28T00:00:00.000Z"); }); }); // ─── getMonthKeys ──────────────────────────────────────────────────────────── describe("getMonthKeys", () => { it("generates keys for Q1 2026", () => { const keys = getMonthKeys(new Date("2026-01-01"), new Date("2026-03-31")); expect(keys).toEqual(["2026-01", "2026-02", "2026-03"]); }); it("single month", () => { const keys = getMonthKeys(new Date("2026-06-15"), new Date("2026-06-20")); expect(keys).toEqual(["2026-06"]); }); it("cross-year boundary", () => { const keys = getMonthKeys(new Date("2025-11-01"), new Date("2026-02-28")); expect(keys).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]); }); }); // ─── countWorkingDaysInOverlap ─────────────────────────────────────────────── describe("countWorkingDaysInOverlap", () => { it("full overlap on a workweek = 5 days", () => { const mon = new Date("2026-01-05"); const fri = new Date("2026-01-09"); expect(countWorkingDaysInOverlap(mon, fri, mon, fri)).toBe(5); }); it("no overlap returns 0", () => { expect( countWorkingDaysInOverlap( new Date("2026-01-05"), new Date("2026-01-09"), new Date("2026-01-12"), new Date("2026-01-16"), ), ).toBe(0); }); it("partial overlap: assignment starts mid-week", () => { // Period Mon-Fri, assignment Wed-next Mon → overlap Wed-Fri = 3 expect( countWorkingDaysInOverlap( new Date("2026-01-05"), // Mon new Date("2026-01-09"), // Fri new Date("2026-01-07"), // Wed new Date("2026-01-12"), // next Mon ), ).toBe(3); }); it("excludes weekends from overlap", () => { // Period: Mon-next Fri (10 calendar days, 10 working days) // Assignment: entire same range expect( countWorkingDaysInOverlap( new Date("2026-01-05"), // Mon new Date("2026-01-16"), // Fri new Date("2026-01-05"), new Date("2026-01-16"), ), ).toBe(10); }); it("weekend-only overlap returns 0", () => { expect( countWorkingDaysInOverlap( new Date("2026-01-10"), // Sat new Date("2026-01-11"), // Sun new Date("2026-01-10"), new Date("2026-01-11"), ), ).toBe(0); }); });