276 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|