Files
CapaKraken/packages/engine/src/__tests__/chargeability-calculator.test.ts
T

276 lines
8.9 KiB
TypeScript

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);
});
});