279 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|