Files
CapaKraken/packages/staffing/src/__tests__/capacity-analyzer.test.ts
T

279 lines
7.8 KiB
TypeScript

import { AllocationStatus } from "@capakraken/shared";
import { describe, expect, it } from "vitest";
import { analyzeUtilization, findCapacityWindows } from "../capacity-analyzer.js";
import type { CapacityAnalysisInput } from "../capacity-analyzer.js";
const standardAvailability = {
sunday: 0,
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
};
const resource = {
id: "res-1",
displayName: "Jane Doe",
chargeabilityTarget: 80,
availability: standardAvailability,
};
describe("analyzeUtilization", () => {
it("computes chargeability for a single chargeable allocation", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [
{
startDate: new Date("2026-03-09"), // Monday
endDate: new Date("2026-03-13"), // Friday
hoursPerDay: 8,
status: AllocationStatus.CONFIRMED,
projectName: "Project A",
isChargeable: true,
},
],
analysisStart: new Date("2026-03-09"),
analysisEnd: new Date("2026-03-13"),
};
const result = analyzeUtilization(input);
expect(result.resourceId).toBe("res-1");
expect(result.currentChargeability).toBe(100);
expect(result.chargeabilityGap).toBe(-20); // target 80, actual 100
expect(result.overallocatedDays).toHaveLength(0);
expect(result.underutilizedDays).toHaveLength(0);
});
it("detects overallocated days", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 5,
status: AllocationStatus.CONFIRMED,
projectName: "A",
isChargeable: true,
},
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 5,
status: AllocationStatus.CONFIRMED,
projectName: "B",
isChargeable: true,
},
],
analysisStart: new Date("2026-03-09"),
analysisEnd: new Date("2026-03-09"),
};
const result = analyzeUtilization(input);
expect(result.overallocatedDays.length).toBeGreaterThanOrEqual(1);
});
it("detects underutilized days (below 50%)", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 2,
status: AllocationStatus.CONFIRMED,
projectName: "A",
isChargeable: true,
},
],
analysisStart: new Date("2026-03-09"),
analysisEnd: new Date("2026-03-09"),
};
const result = analyzeUtilization(input);
expect(result.underutilizedDays).toHaveLength(1);
});
it("ignores allocations with inactive status", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-13"),
hoursPerDay: 8,
status: AllocationStatus.CANCELLED,
projectName: "Cancelled",
isChargeable: true,
},
],
analysisStart: new Date("2026-03-09"),
analysisEnd: new Date("2026-03-13"),
};
const result = analyzeUtilization(input);
expect(result.currentChargeability).toBe(0);
expect(result.allocations).toHaveLength(0);
});
it("separates chargeable from non-chargeable hours", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
projectName: "Chargeable",
isChargeable: true,
},
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
projectName: "Internal",
isChargeable: false,
},
],
analysisStart: new Date("2026-03-09"),
analysisEnd: new Date("2026-03-09"),
};
const result = analyzeUtilization(input);
expect(result.currentChargeability).toBe(50);
expect(result.overallocatedDays).toHaveLength(0);
});
it("skips weekends", () => {
const input: CapacityAnalysisInput = {
resource,
allocations: [],
analysisStart: new Date("2026-03-07"), // Saturday
analysisEnd: new Date("2026-03-08"), // Sunday
};
const result = analyzeUtilization(input);
expect(result.currentChargeability).toBe(0);
expect(result.overallocatedDays).toHaveLength(0);
expect(result.underutilizedDays).toHaveLength(0);
});
});
describe("findCapacityWindows", () => {
const simpleResource = {
id: "res-1",
displayName: "Jane Doe",
availability: standardAvailability,
};
it("finds a full-week window with no allocations", () => {
const windows = findCapacityWindows(
simpleResource,
[],
new Date("2026-03-09"), // Monday
new Date("2026-03-13"), // Friday
);
expect(windows).toHaveLength(1);
expect(windows[0]!.availableDays).toBe(5);
expect(windows[0]!.availableHoursPerDay).toBe(8);
expect(windows[0]!.totalAvailableHours).toBe(40);
});
it("reduces available hours when partially allocated", () => {
const windows = findCapacityWindows(
simpleResource,
[
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-13"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
},
],
new Date("2026-03-09"),
new Date("2026-03-13"),
);
expect(windows).toHaveLength(1);
expect(windows[0]!.availableHoursPerDay).toBe(4);
expect(windows[0]!.totalAvailableHours).toBe(20);
});
it("returns no windows when fully allocated", () => {
const windows = findCapacityWindows(
simpleResource,
[
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-13"),
hoursPerDay: 8,
status: AllocationStatus.CONFIRMED,
},
],
new Date("2026-03-09"),
new Date("2026-03-13"),
);
expect(windows).toHaveLength(0);
});
it("ignores cancelled allocations", () => {
const windows = findCapacityWindows(
simpleResource,
[
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-13"),
hoursPerDay: 8,
status: AllocationStatus.CANCELLED,
},
],
new Date("2026-03-09"),
new Date("2026-03-13"),
);
expect(windows).toHaveLength(1);
expect(windows[0]!.availableDays).toBe(5);
});
it("splits windows around gaps", () => {
const windows = findCapacityWindows(
simpleResource,
[
{
startDate: new Date("2026-03-11"), // Wed only fully booked
endDate: new Date("2026-03-11"),
hoursPerDay: 8,
status: AllocationStatus.CONFIRMED,
},
],
new Date("2026-03-09"), // Mon
new Date("2026-03-13"), // Fri
);
// Two windows: Mon-Tue, Thu-Fri
expect(windows).toHaveLength(2);
expect(windows[0]!.availableDays).toBe(2);
expect(windows[1]!.availableDays).toBe(2);
});
it("respects minAvailableHoursPerDay threshold", () => {
const windows = findCapacityWindows(
simpleResource,
[
{
startDate: new Date("2026-03-09"),
endDate: new Date("2026-03-09"),
hoursPerDay: 6,
status: AllocationStatus.CONFIRMED,
},
],
new Date("2026-03-09"),
new Date("2026-03-09"),
4, // need at least 4 hours free
);
// Only 2 hours free (8-6), threshold is 4 → no window
expect(windows).toHaveLength(0);
});
});