chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectOverlaps, validateAvailability } from "../allocation/availability-validator.js";
|
||||
|
||||
const stdAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
};
|
||||
|
||||
describe("validateAvailability", () => {
|
||||
it("passes when no existing allocations", () => {
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
8,
|
||||
stdAvailability,
|
||||
[],
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects conflict when combined hours exceed availability", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 6,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
4, // 6+4=10 > 8
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.conflicts).toHaveLength(5); // All 5 working days
|
||||
});
|
||||
|
||||
it("passes when combined hours exactly match availability", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 4,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
4, // 4+4=8 ≤ 8
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores cancelled allocations", () => {
|
||||
const existing = [{
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CANCELLED,
|
||||
}];
|
||||
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-06"),
|
||||
new Date("2025-01-10"),
|
||||
8,
|
||||
stdAvailability,
|
||||
existing,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("skips non-working days in conflict check", () => {
|
||||
const result = validateAvailability(
|
||||
new Date("2025-01-04"), // Saturday
|
||||
new Date("2025-01-05"), // Sunday
|
||||
8,
|
||||
stdAvailability,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectOverlaps", () => {
|
||||
const existingAlloc = {
|
||||
id: "alloc-1",
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-10"),
|
||||
projectId: "proj-1",
|
||||
status: AllocationStatus.ACTIVE,
|
||||
};
|
||||
|
||||
it("detects overlapping allocations", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[existingAlloc],
|
||||
);
|
||||
expect(overlaps).toContain("alloc-1");
|
||||
});
|
||||
|
||||
it("returns empty for non-overlapping date ranges", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-13"), // Starts after existing ends
|
||||
new Date("2025-01-17"),
|
||||
[existingAlloc],
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes same-project allocations when excludeProjectId provided", () => {
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[existingAlloc],
|
||||
"proj-1", // Exclude this project
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores cancelled allocations", () => {
|
||||
const cancelled = { ...existingAlloc, status: AllocationStatus.CANCELLED };
|
||||
const overlaps = detectOverlaps(
|
||||
new Date("2025-01-08"),
|
||||
new Date("2025-01-15"),
|
||||
[cancelled],
|
||||
);
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeBudgetStatus } from "../budget/monitor.js";
|
||||
|
||||
const now = new Date("2025-01-06");
|
||||
const endDate = new Date("2025-03-07");
|
||||
|
||||
const mkAlloc = (
|
||||
hoursPerDay: number,
|
||||
dailyCostCents: number,
|
||||
status: AllocationStatus = AllocationStatus.CONFIRMED,
|
||||
) => ({
|
||||
status,
|
||||
dailyCostCents,
|
||||
hoursPerDay,
|
||||
startDate: now,
|
||||
endDate,
|
||||
});
|
||||
|
||||
describe("computeBudgetStatus", () => {
|
||||
it("returns 0% utilization for no allocations", () => {
|
||||
const result = computeBudgetStatus(1000000, 100, [], now, endDate);
|
||||
expect(result.allocatedCents).toBe(0);
|
||||
expect(result.utilizationPercent).toBe(0);
|
||||
expect(result.remainingCents).toBe(1000000);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calculates correct utilization", () => {
|
||||
// 1 alloc, 10 working days, 8000 cents/day = 80000 cents
|
||||
const alloc = { ...mkAlloc(8, 8000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(400000, 100, [alloc], now, endDate);
|
||||
expect(result.confirmedCents).toBe(80000);
|
||||
expect(result.utilizationPercent).toBeCloseTo(20, 0);
|
||||
});
|
||||
|
||||
it("emits WARNING at 85% utilization", () => {
|
||||
// Budget 100000, alloc cost 87000 = 87%
|
||||
const alloc = { ...mkAlloc(8, 87000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasWarning = result.warnings.some((w) => w.code === "BUDGET_WARNING");
|
||||
expect(hasWarning).toBe(true);
|
||||
});
|
||||
|
||||
it("emits CRITICAL at 95% utilization", () => {
|
||||
const alloc = { ...mkAlloc(8, 96000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasCritical = result.warnings.some((w) => w.code === "BUDGET_CRITICAL");
|
||||
expect(hasCritical).toBe(true);
|
||||
});
|
||||
|
||||
it("emits EXCEEDED when allocations exceed budget", () => {
|
||||
const alloc = { ...mkAlloc(8, 120000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
|
||||
const hasExceeded = result.warnings.some((w) => w.code === "BUDGET_EXCEEDED");
|
||||
expect(hasExceeded).toBe(true);
|
||||
});
|
||||
|
||||
it("applies win probability weighting", () => {
|
||||
const alloc = { ...mkAlloc(8, 10000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
|
||||
const result = computeBudgetStatus(1000000, 50, [alloc], now, endDate);
|
||||
// allocated = 10 days × 10000 = 100000, weighted = 50% = 50000
|
||||
expect(result.winProbabilityWeightedCents).toBe(Math.round(result.allocatedCents * 0.5));
|
||||
});
|
||||
|
||||
it("separates proposed from confirmed costs", () => {
|
||||
const confirmed = { ...mkAlloc(8, 5000, AllocationStatus.CONFIRMED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
|
||||
const proposed = { ...mkAlloc(8, 5000, AllocationStatus.PROPOSED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
|
||||
const result = computeBudgetStatus(1000000, 100, [confirmed, proposed], now, endDate);
|
||||
expect(result.confirmedCents).toBeGreaterThan(0);
|
||||
expect(result.proposedCents).toBeGreaterThan(0);
|
||||
expect(result.confirmedCents).toBe(result.proposedCents);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateAllocation,
|
||||
calculateTotalCost,
|
||||
countWorkingDays,
|
||||
isWorkday,
|
||||
} from "../allocation/calculator.js";
|
||||
|
||||
const stdAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
};
|
||||
|
||||
describe("isWorkday", () => {
|
||||
it("returns true for Monday", () => {
|
||||
expect(isWorkday(new Date("2025-01-06"), stdAvailability)).toBe(true); // Monday
|
||||
});
|
||||
|
||||
it("returns false for Saturday", () => {
|
||||
expect(isWorkday(new Date("2025-01-04"), stdAvailability)).toBe(false); // Saturday
|
||||
});
|
||||
|
||||
it("returns false for Sunday", () => {
|
||||
expect(isWorkday(new Date("2025-01-05"), stdAvailability)).toBe(false); // Sunday
|
||||
});
|
||||
});
|
||||
|
||||
describe("countWorkingDays", () => {
|
||||
it("counts 5 days in a Mon-Fri week", () => {
|
||||
const start = new Date("2025-01-06"); // Monday
|
||||
const end = new Date("2025-01-10"); // Friday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(5);
|
||||
});
|
||||
|
||||
it("counts 10 days over two Mon-Fri weeks", () => {
|
||||
const start = new Date("2025-01-06"); // Monday
|
||||
const end = new Date("2025-01-17"); // Friday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(10);
|
||||
});
|
||||
|
||||
it("counts 0 for a weekend-only range", () => {
|
||||
const start = new Date("2025-01-04"); // Saturday
|
||||
const end = new Date("2025-01-05"); // Sunday
|
||||
expect(countWorkingDays(start, end, stdAvailability)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts same day as 1 if it's a workday", () => {
|
||||
const day = new Date("2025-01-06"); // Monday
|
||||
expect(countWorkingDays(day, day, stdAvailability)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateAllocation", () => {
|
||||
it("calculates cost for a full week at 8h/day and 100 EUR/h (10000 cents)", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000, // 100 EUR/h
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-06"), // Monday
|
||||
endDate: new Date("2025-01-10"), // Friday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
expect(result.totalHours).toBe(40);
|
||||
// 5 days × 8h × 10000 cents/h = 400000 cents = 4000 EUR
|
||||
expect(result.totalCostCents).toBe(400000);
|
||||
expect(result.dailyCostCents).toBe(80000);
|
||||
expect(result.dailyBreakdown).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("skips weekend days in breakdown", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-04"), // Saturday
|
||||
endDate: new Date("2025-01-12"), // Sunday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
const weekendDays = result.dailyBreakdown.filter((d) => !d.isWorkday);
|
||||
expect(weekendDays).toHaveLength(4); // 2 weekends
|
||||
});
|
||||
|
||||
it("caps hours at available hours if requested > available", () => {
|
||||
const limitedAvailability = { ...stdAvailability, monday: 4 };
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8, // Requested more than available
|
||||
startDate: new Date("2025-01-06"), // Monday only
|
||||
endDate: new Date("2025-01-06"),
|
||||
availability: limitedAvailability,
|
||||
});
|
||||
|
||||
expect(result.totalHours).toBe(4); // Capped at availability
|
||||
expect(result.totalCostCents).toBe(40000); // 4h × 10000 cents
|
||||
});
|
||||
|
||||
it("returns zero for date range with no working days", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2025-01-04"), // Saturday
|
||||
endDate: new Date("2025-01-05"), // Sunday
|
||||
availability: stdAvailability,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(0);
|
||||
expect(result.totalHours).toBe(0);
|
||||
expect(result.totalCostCents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateTotalCost", () => {
|
||||
it("calculates correctly", () => {
|
||||
// 10000 cents/h × 8h × 5 days = 400000 cents
|
||||
expect(calculateTotalCost(10000, 8, 5)).toBe(400000);
|
||||
});
|
||||
|
||||
it("returns 0 for 0 working days", () => {
|
||||
expect(calculateTotalCost(10000, 8, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeAvailableHours,
|
||||
computeBookedHours,
|
||||
computeChargeability,
|
||||
} from "../allocation/chargeability.js";
|
||||
|
||||
// Standard Mon–Fri 8h availability
|
||||
const stdAvail = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
|
||||
|
||||
// Week of 2025-01-06 (Mon) to 2025-01-10 (Fri)
|
||||
const weekStart = new Date("2025-01-06");
|
||||
const weekEnd = new Date("2025-01-10");
|
||||
|
||||
describe("computeAvailableHours", () => {
|
||||
it("Mon–Fri 8h resource: 1 calendar week = 40 available hours", () => {
|
||||
expect(computeAvailableHours(stdAvail, weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("excludes Saturday and Sunday when not in availability", () => {
|
||||
// 2025-01-04 (Sat) to 2025-01-05 (Sun)
|
||||
expect(computeAvailableHours(stdAvail, new Date("2025-01-04"), new Date("2025-01-05"))).toBe(0);
|
||||
});
|
||||
|
||||
it("counts Saturday when enabled in availability", () => {
|
||||
const avail = { ...stdAvail, saturday: 4 };
|
||||
// Mon 06 to Sat 11 Jan: 5 weekdays × 8 + 1 Saturday × 4 = 44
|
||||
expect(computeAvailableHours(avail, new Date("2025-01-06"), new Date("2025-01-11"))).toBe(44);
|
||||
});
|
||||
|
||||
it("single day period (Monday): returns daily hours", () => {
|
||||
expect(computeAvailableHours(stdAvail, weekStart, weekStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBookedHours", () => {
|
||||
it("no allocations → bookedHours = 0", () => {
|
||||
expect(computeBookedHours(stdAvail, [], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
|
||||
it("full 8h allocation covering entire period counts all working days", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("allocation partially overlapping: only overlap days counted", () => {
|
||||
// Allocation spans Wed-Sun; period is Mon-Fri → overlap is Wed-Fri (3 days)
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-08"), // Wed
|
||||
endDate: new Date("2025-01-12"), // Sun
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(24); // 3 days × 8
|
||||
});
|
||||
|
||||
it("allocation spanning weekends: weekends excluded from booked hours", () => {
|
||||
// Mon 06 to Mon 13 (spans a weekend), but weekend has 0 avail
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-06"),
|
||||
endDate: new Date("2025-01-13"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
// Period is Mon-Fri week only
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(40);
|
||||
});
|
||||
|
||||
it("Saturday with availability counted when allocation covers it", () => {
|
||||
const avail = { ...stdAvail, saturday: 4 };
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-06"), // Mon
|
||||
endDate: new Date("2025-01-11"), // Sat
|
||||
hoursPerDay: 6,
|
||||
};
|
||||
// Mon–Fri: 5 days × 6 = 30, Sat: 1 day × 6 = 6 → total 36
|
||||
expect(computeBookedHours(avail, [alloc], new Date("2025-01-06"), new Date("2025-01-11"))).toBe(36);
|
||||
});
|
||||
|
||||
it("allocation entirely before period: no hours counted", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2024-12-30"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
|
||||
it("allocation entirely after period: no hours counted", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-13"),
|
||||
endDate: new Date("2025-01-17"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
expect(computeBookedHours(stdAvail, [alloc], weekStart, weekEnd)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeChargeability", () => {
|
||||
it("no allocations → chargeability = 0, bookedHours = 0", () => {
|
||||
const result = computeChargeability(stdAvail, [], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(0);
|
||||
expect(result.bookedHours).toBe(0);
|
||||
expect(result.availableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("full 8h allocation covering entire period → chargeability = 100", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(100);
|
||||
expect(result.bookedHours).toBe(40);
|
||||
});
|
||||
|
||||
it("50% allocation → chargeability = 50", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(50);
|
||||
expect(result.bookedHours).toBe(20);
|
||||
});
|
||||
|
||||
it("over-allocated (booked > available): chargeability capped at 100", () => {
|
||||
// Two allocations totalling 12h/day on a resource with 8h availability
|
||||
const allocs = [
|
||||
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 8 },
|
||||
{ startDate: weekStart, endDate: weekEnd, hoursPerDay: 4 },
|
||||
];
|
||||
const result = computeChargeability(stdAvail, allocs, weekStart, weekEnd);
|
||||
expect(result.chargeability).toBe(100);
|
||||
expect(result.bookedHours).toBe(60); // not capped in raw value
|
||||
});
|
||||
|
||||
it("single day period (start === end): correct result", () => {
|
||||
const alloc = { startDate: weekStart, endDate: weekStart, hoursPerDay: 4 };
|
||||
const result = computeChargeability(stdAvail, [alloc], weekStart, weekStart);
|
||||
expect(result.availableHours).toBe(8);
|
||||
expect(result.bookedHours).toBe(4);
|
||||
expect(result.chargeability).toBe(50);
|
||||
});
|
||||
|
||||
it("zero available hours (all weekend period): chargeability = 0", () => {
|
||||
const alloc = {
|
||||
startDate: new Date("2025-01-04"),
|
||||
endDate: new Date("2025-01-05"),
|
||||
hoursPerDay: 8,
|
||||
};
|
||||
const result = computeChargeability(stdAvail, [alloc], new Date("2025-01-04"), new Date("2025-01-05"));
|
||||
expect(result.availableHours).toBe(0);
|
||||
expect(result.chargeability).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
expandScopeToEffort,
|
||||
aggregateByDiscipline,
|
||||
type EffortRuleInput,
|
||||
type ScopeItemInput,
|
||||
} from "../estimate/effort-rules.js";
|
||||
|
||||
const STANDARD_RULES: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "3D Animation", chapter: "Animation", unitMode: "per_frame", hoursPerUnit: 0.5, sortOrder: 0 },
|
||||
{ scopeType: "SHOT", discipline: "3D Lighting", chapter: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.2, sortOrder: 1 },
|
||||
{ scopeType: "SHOT", discipline: "Compositing", chapter: "Compositing", unitMode: "per_frame", hoursPerUnit: 0.15, sortOrder: 2 },
|
||||
{ scopeType: "ASSET", discipline: "3D Modeling", chapter: "Modeling", unitMode: "per_item", hoursPerUnit: 40, sortOrder: 0 },
|
||||
{ scopeType: "ASSET", discipline: "3D Rigging", chapter: "Rigging", unitMode: "per_item", hoursPerUnit: 24, sortOrder: 1 },
|
||||
{ scopeType: "ENVIRONMENT", discipline: "3D Environment", chapter: "Environment", unitMode: "flat", hoursPerUnit: 80, sortOrder: 0 },
|
||||
];
|
||||
|
||||
describe("expandScopeToEffort", () => {
|
||||
it("expands a single shot scope item into multiple discipline lines", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 120 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(3);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.unmatchedScopeItems).toHaveLength(0);
|
||||
|
||||
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
|
||||
expect(anim.hours).toBe(60); // 120 * 0.5
|
||||
expect(anim.unitCount).toBe(120);
|
||||
expect(anim.chapter).toBe("Animation");
|
||||
|
||||
const lighting = result.lines.find((l) => l.discipline === "3D Lighting")!;
|
||||
expect(lighting.hours).toBe(24); // 120 * 0.2
|
||||
|
||||
const comp = result.lines.find((l) => l.discipline === "Compositing")!;
|
||||
expect(comp.hours).toBe(18); // 120 * 0.15
|
||||
});
|
||||
|
||||
it("expands asset scope items using per_item mode", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Hero Character", scopeType: "ASSET", itemCount: 3 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(2);
|
||||
const modeling = result.lines.find((l) => l.discipline === "3D Modeling")!;
|
||||
expect(modeling.hours).toBe(120); // 3 * 40
|
||||
const rigging = result.lines.find((l) => l.discipline === "3D Rigging")!;
|
||||
expect(rigging.hours).toBe(72); // 3 * 24
|
||||
});
|
||||
|
||||
it("uses flat hours for environment scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Forest Scene", scopeType: "ENVIRONMENT" },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0]!.hours).toBe(80); // flat
|
||||
expect(result.lines[0]!.unitCount).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to 1 when frameCount is null in per_frame mode", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot X", scopeType: "SHOT", frameCount: null },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
const anim = result.lines.find((l) => l.discipline === "3D Animation")!;
|
||||
expect(anim.unitCount).toBe(1);
|
||||
expect(anim.hours).toBe(0.5); // 1 * 0.5
|
||||
});
|
||||
|
||||
it("handles multiple scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
|
||||
{ name: "Prop A", scopeType: "ASSET", itemCount: 1 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
// 2 shots * 3 rules + 1 asset * 2 rules = 8 lines
|
||||
expect(result.lines).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("reports unmatched scope items", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Audio Track", scopeType: "AUDIO" },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.unmatchedScopeItems).toContain("Audio Track");
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches scope type case-insensitively", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "shot", frameCount: 50 },
|
||||
];
|
||||
const result = expandScopeToEffort(items, STANDARD_RULES);
|
||||
expect(result.lines).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("warns when no rules provided", () => {
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
|
||||
[],
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.warnings).toContain("No effort rules provided.");
|
||||
});
|
||||
|
||||
it("skips lines with zero hours", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "QC", unitMode: "per_frame", hoursPerUnit: 0, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }],
|
||||
rules,
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped"))).toBe(true);
|
||||
});
|
||||
|
||||
it("skips empty-name scope items", () => {
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "", scopeType: "SHOT", frameCount: 100 }],
|
||||
STANDARD_RULES,
|
||||
);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rounds hours to 2 decimal places", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "FX", unitMode: "per_frame", hoursPerUnit: 0.333, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT", frameCount: 7 }],
|
||||
rules,
|
||||
);
|
||||
// 7 * 0.333 = 2.331
|
||||
expect(result.lines[0]!.hours).toBe(2.33);
|
||||
});
|
||||
|
||||
it("preserves sortOrder in output order", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "Z-Last", unitMode: "flat", hoursPerUnit: 1, sortOrder: 10 },
|
||||
{ scopeType: "SHOT", discipline: "A-First", unitMode: "flat", hoursPerUnit: 1, sortOrder: 0 },
|
||||
];
|
||||
const result = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT" }],
|
||||
rules,
|
||||
);
|
||||
expect(result.lines[0]!.discipline).toBe("A-First");
|
||||
expect(result.lines[1]!.discipline).toBe("Z-Last");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateByDiscipline", () => {
|
||||
it("sums hours across scope items per discipline", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
{ name: "Shot 002", scopeType: "SHOT", frameCount: 200 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
const anim = aggregated.find((a) => a.discipline === "3D Animation")!;
|
||||
expect(anim.totalHours).toBe(150); // (100+200) * 0.5
|
||||
expect(anim.lineCount).toBe(2);
|
||||
});
|
||||
|
||||
it("sorts by totalHours descending", () => {
|
||||
const items: ScopeItemInput[] = [
|
||||
{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(items, STANDARD_RULES);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
expect(aggregated[0]!.totalHours).toBeGreaterThanOrEqual(aggregated[1]!.totalHours);
|
||||
});
|
||||
|
||||
it("keeps discipline+chapter pairs separate", () => {
|
||||
const rules: EffortRuleInput[] = [
|
||||
{ scopeType: "SHOT", discipline: "Animation", chapter: "2D", unitMode: "flat", hoursPerUnit: 10, sortOrder: 0 },
|
||||
{ scopeType: "SHOT", discipline: "Animation", chapter: "3D", unitMode: "flat", hoursPerUnit: 20, sortOrder: 1 },
|
||||
];
|
||||
const { lines } = expandScopeToEffort(
|
||||
[{ name: "Shot 001", scopeType: "SHOT" }],
|
||||
rules,
|
||||
);
|
||||
const aggregated = aggregateByDiscipline(lines);
|
||||
|
||||
expect(aggregated).toHaveLength(2);
|
||||
const anim2d = aggregated.find((a) => a.chapter === "2D")!;
|
||||
expect(anim2d.totalHours).toBe(10);
|
||||
const anim3d = aggregated.find((a) => a.chapter === "3D")!;
|
||||
expect(anim3d.totalHours).toBe(20);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(aggregateByDiscipline([])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
serializeEstimateExport,
|
||||
type EstimateExportSource,
|
||||
} from "../estimate/export-serializer.js";
|
||||
|
||||
const createdAt = new Date("2026-03-13T08:00:00.000Z");
|
||||
|
||||
function buildSource(): EstimateExportSource {
|
||||
return {
|
||||
estimate: {
|
||||
id: "est_1",
|
||||
projectId: "project_1",
|
||||
name: "CGI Estimate",
|
||||
opportunityId: "OP-42",
|
||||
baseCurrency: "EUR",
|
||||
status: EstimateStatus.APPROVED,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
project: {
|
||||
id: "project_1",
|
||||
shortCode: "CGI-001",
|
||||
name: "CGI Project",
|
||||
status: "ACTIVE",
|
||||
startDate: "2026-03-01T00:00:00.000Z",
|
||||
endDate: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
version: {
|
||||
id: "ver_1",
|
||||
versionNumber: 1,
|
||||
label: "Approved",
|
||||
status: EstimateVersionStatus.APPROVED,
|
||||
notes: "Ready for export",
|
||||
lockedAt: createdAt,
|
||||
projectSnapshot: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
assumptions: [
|
||||
{
|
||||
id: "assumption_1",
|
||||
category: "commercial",
|
||||
key: "pricingStructure",
|
||||
label: "Pricing Structure",
|
||||
valueType: "string",
|
||||
value: "fixed-bid",
|
||||
sortOrder: 0,
|
||||
notes: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
scopeItems: [
|
||||
{
|
||||
id: "scope_1",
|
||||
sequenceNo: 10,
|
||||
scopeType: "SHOT",
|
||||
packageCode: "PKG-1",
|
||||
name: "Shot 010",
|
||||
description: "Main hero shot",
|
||||
scene: "Scene 01",
|
||||
page: null,
|
||||
location: "Berlin",
|
||||
assumptionCategory: null,
|
||||
technicalSpec: { resolution: "4K" },
|
||||
frameCount: 120,
|
||||
itemCount: 1,
|
||||
unitMode: "shot",
|
||||
internalComments: null,
|
||||
externalComments: null,
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
demandLines: [
|
||||
{
|
||||
id: "line_1",
|
||||
scopeItemId: "scope_1",
|
||||
roleId: "role_1",
|
||||
resourceId: "resource_1",
|
||||
lineType: "LABOR",
|
||||
name: "Comp Artist",
|
||||
chapter: "Compositing",
|
||||
hours: 40,
|
||||
days: 5,
|
||||
fte: 1,
|
||||
rateSource: "resource",
|
||||
costRateCents: 5000,
|
||||
billRateCents: 8000,
|
||||
currency: "EUR",
|
||||
costTotalCents: 200000,
|
||||
priceTotalCents: 320000,
|
||||
monthlySpread: { "2026-03": 40 },
|
||||
staffingAttributes: { location: "Berlin" },
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
resourceSnapshots: [
|
||||
{
|
||||
id: "snapshot_1",
|
||||
resourceId: "resource_1",
|
||||
sourceEid: "E100",
|
||||
displayName: "Alex Artist",
|
||||
chapter: "Compositing",
|
||||
roleId: "role_1",
|
||||
currency: "EUR",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 8000,
|
||||
fte: 1,
|
||||
location: "Berlin",
|
||||
country: "DE",
|
||||
level: "Senior",
|
||||
workType: "Remote",
|
||||
attributes: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: "metric_1",
|
||||
key: "total_hours",
|
||||
label: "Total Hours",
|
||||
metricGroup: "summary",
|
||||
valueDecimal: 40,
|
||||
valueCents: null,
|
||||
currency: null,
|
||||
metadata: {},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("estimate export serializer", () => {
|
||||
it("creates a structured JSON export payload", () => {
|
||||
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.JSON);
|
||||
|
||||
expect(payload.encoding).toBe("utf8");
|
||||
expect(payload.mimeType).toBe("application/json; charset=utf-8");
|
||||
expect(payload.summary.totalHours).toBe(40);
|
||||
expect(payload.content).toContain('"estimateId": "est_1"');
|
||||
expect(payload.previewText).toContain('"schemaVersion": 1');
|
||||
});
|
||||
|
||||
it("creates a multi-sheet xlsx export payload", () => {
|
||||
const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX);
|
||||
const workbook = XLSX.read(payload.content, { type: "base64" });
|
||||
|
||||
expect(payload.encoding).toBe("base64");
|
||||
expect(payload.sheetNames).toEqual([
|
||||
"Overview",
|
||||
"Assumptions",
|
||||
"Scope",
|
||||
"DemandLines",
|
||||
"Resources",
|
||||
"Metrics",
|
||||
]);
|
||||
expect(workbook.SheetNames).toContain("DemandLines");
|
||||
expect(payload.byteLength).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeEstimateDemandLine,
|
||||
summarizeEstimateDemandLines,
|
||||
} from "../estimate/index.js";
|
||||
|
||||
describe("summarizeEstimateDemandLines", () => {
|
||||
it("aggregates hours, totals, and margin", () => {
|
||||
expect(
|
||||
summarizeEstimateDemandLines([
|
||||
{ hours: 10, costTotalCents: 100_00, priceTotalCents: 180_00 },
|
||||
{ hours: 6.5, costTotalCents: 80_00, priceTotalCents: 120_00 },
|
||||
]),
|
||||
).toEqual({
|
||||
totalHours: 16.5,
|
||||
totalCostCents: 180_00,
|
||||
totalPriceCents: 300_00,
|
||||
marginCents: 120_00,
|
||||
marginPercent: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns zero margin percent when no price exists", () => {
|
||||
expect(
|
||||
summarizeEstimateDemandLines([
|
||||
{ hours: 4, costTotalCents: 50_00, priceTotalCents: 0 },
|
||||
]),
|
||||
).toEqual({
|
||||
totalHours: 4,
|
||||
totalCostCents: 50_00,
|
||||
totalPriceCents: 0,
|
||||
marginCents: -50_00,
|
||||
marginPercent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEstimateDemandLine", () => {
|
||||
it("recomputes linked live rates and totals from the resource snapshot", () => {
|
||||
expect(
|
||||
normalizeEstimateDemandLine(
|
||||
{
|
||||
resourceId: "res_1",
|
||||
hours: 12,
|
||||
rateSource: "RESOURCE",
|
||||
costRateCents: 4000,
|
||||
billRateCents: 6500,
|
||||
currency: "EUR",
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceSnapshot: {
|
||||
resourceId: "res_1",
|
||||
currency: "USD",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 8000,
|
||||
},
|
||||
defaultCurrency: "EUR",
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
costRateCents: 5000,
|
||||
billRateCents: 8000,
|
||||
currency: "USD",
|
||||
costTotalCents: 60000,
|
||||
priceTotalCents: 96000,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: 5000,
|
||||
liveBillRateCents: 8000,
|
||||
liveCurrency: "USD",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves manual override rates while still updating trace metadata", () => {
|
||||
expect(
|
||||
normalizeEstimateDemandLine(
|
||||
{
|
||||
resourceId: "res_2",
|
||||
hours: 10,
|
||||
rateSource: "RESOURCE",
|
||||
costRateCents: 7200,
|
||||
billRateCents: 9500,
|
||||
currency: "EUR",
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "manual",
|
||||
billRateMode: "resource",
|
||||
totalMode: "computed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceSnapshot: {
|
||||
resourceId: "res_2",
|
||||
currency: "EUR",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
},
|
||||
defaultCurrency: "EUR",
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
costRateCents: 7200,
|
||||
billRateCents: 9000,
|
||||
costTotalCents: 72000,
|
||||
priceTotalCents: 90000,
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "manual",
|
||||
billRateMode: "resource",
|
||||
liveCostRateCents: 5000,
|
||||
liveBillRateCents: 9000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findBestMatchingRule,
|
||||
applyExperienceMultipliers,
|
||||
applyExperienceMultipliersBatch,
|
||||
type ExperienceMultiplierRule,
|
||||
type RateAdjustmentInput,
|
||||
} from "../estimate/experience-multiplier.js";
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const globalRule: ExperienceMultiplierRule = {
|
||||
costMultiplier: 1.1,
|
||||
billMultiplier: 1.1,
|
||||
description: "Global 10% uplift",
|
||||
};
|
||||
|
||||
const chapterRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
costMultiplier: 1.2,
|
||||
billMultiplier: 1.3,
|
||||
};
|
||||
|
||||
const locationRule: ExperienceMultiplierRule = {
|
||||
location: "India",
|
||||
costMultiplier: 0.6,
|
||||
billMultiplier: 0.8,
|
||||
};
|
||||
|
||||
const levelRule: ExperienceMultiplierRule = {
|
||||
level: "Senior",
|
||||
costMultiplier: 1.5,
|
||||
billMultiplier: 1.6,
|
||||
};
|
||||
|
||||
const chapterLocationRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
costMultiplier: 0.7,
|
||||
billMultiplier: 0.85,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0.2,
|
||||
};
|
||||
|
||||
const chapterLevelRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
level: "Senior",
|
||||
costMultiplier: 1.4,
|
||||
billMultiplier: 1.5,
|
||||
};
|
||||
|
||||
const locationLevelRule: ExperienceMultiplierRule = {
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
costMultiplier: 0.8,
|
||||
billMultiplier: 0.9,
|
||||
};
|
||||
|
||||
const exactRule: ExperienceMultiplierRule = {
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
costMultiplier: 0.9,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.3,
|
||||
additionalEffortRatio: 0.1,
|
||||
description: "Exact match rule",
|
||||
};
|
||||
|
||||
const allRules: ExperienceMultiplierRule[] = [
|
||||
globalRule,
|
||||
chapterRule,
|
||||
locationRule,
|
||||
levelRule,
|
||||
chapterLocationRule,
|
||||
chapterLevelRule,
|
||||
locationLevelRule,
|
||||
exactRule,
|
||||
];
|
||||
|
||||
// ─── findBestMatchingRule ────────────────────────────────────────────────────
|
||||
|
||||
describe("findBestMatchingRule", () => {
|
||||
it("returns null when no rules are provided", () => {
|
||||
expect(findBestMatchingRule({ chapter: "X" }, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no rule matches", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Compositing", costMultiplier: 1, billMultiplier: 1 },
|
||||
];
|
||||
expect(findBestMatchingRule({ chapter: "Animation" }, rules)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches exact chapter+location+level (specificity 7)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "India", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(exactRule);
|
||||
});
|
||||
|
||||
it("matches chapter+location (specificity 6) when level does not match exact", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "India", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterLocationRule);
|
||||
});
|
||||
|
||||
it("matches chapter+level (specificity 5) when location differs", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "Germany", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterLevelRule);
|
||||
});
|
||||
|
||||
it("matches chapter only (specificity 4)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Animation", location: "Germany", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(chapterRule);
|
||||
});
|
||||
|
||||
it("matches location+level (specificity 3)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "India", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(locationLevelRule);
|
||||
});
|
||||
|
||||
it("matches location only (specificity 2)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "India", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(locationRule);
|
||||
});
|
||||
|
||||
it("matches level only (specificity 1)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "Germany", level: "Senior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(levelRule);
|
||||
});
|
||||
|
||||
it("falls back to global rule (specificity 0)", () => {
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "Compositing", location: "Germany", level: "Junior" },
|
||||
allRules,
|
||||
);
|
||||
expect(result).toBe(globalRule);
|
||||
});
|
||||
|
||||
it("is case-insensitive when matching", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "ANIMATION", location: "india", costMultiplier: 1, billMultiplier: 1 },
|
||||
];
|
||||
const result = findBestMatchingRule(
|
||||
{ chapter: "animation", location: "India" },
|
||||
rules,
|
||||
);
|
||||
expect(result).toBe(rules[0]);
|
||||
});
|
||||
|
||||
it("treats null / undefined input fields as wildcard-matchable", () => {
|
||||
const result = findBestMatchingRule({}, [globalRule]);
|
||||
expect(result).toBe(globalRule);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyExperienceMultipliers ─────────────────────────────────────────────
|
||||
|
||||
describe("applyExperienceMultipliers", () => {
|
||||
const baseInput: RateAdjustmentInput = {
|
||||
costRateCents: 10000,
|
||||
billRateCents: 15000,
|
||||
hours: 100,
|
||||
chapter: "Animation",
|
||||
location: "India",
|
||||
level: "Senior",
|
||||
};
|
||||
|
||||
it("returns unchanged values when no rules provided", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, []);
|
||||
expect(result.adjustedCostRateCents).toBe(10000);
|
||||
expect(result.adjustedBillRateCents).toBe(15000);
|
||||
expect(result.adjustedHours).toBe(100);
|
||||
expect(result.appliedRules).toHaveLength(1);
|
||||
expect(result.appliedRules[0]).toContain("No rules provided");
|
||||
});
|
||||
|
||||
it("returns unchanged values when no rule matches", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Compositing", costMultiplier: 2, billMultiplier: 2 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ ...baseInput, chapter: "Animation" },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(10000);
|
||||
expect(result.adjustedBillRateCents).toBe(15000);
|
||||
});
|
||||
|
||||
it("applies rate multipliers correctly and rounds to integer cents", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Animation", costMultiplier: 1.15, billMultiplier: 1.33 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 80, chapter: "Animation" },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(11500); // 10000 * 1.15
|
||||
expect(result.adjustedBillRateCents).toBe(19950); // 15000 * 1.33
|
||||
expect(result.adjustedHours).toBe(80); // no shoring
|
||||
});
|
||||
|
||||
it("handles rounding edge case (e.g. 3333 * 1.1 = 3666.3 => 3666)", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ costMultiplier: 1.1, billMultiplier: 1.0 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 3333, billRateCents: 5000, hours: 10 },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(3666); // Math.round(3666.3)
|
||||
});
|
||||
|
||||
it("applies shoring ratio to hours", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{
|
||||
costMultiplier: 1.0,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0,
|
||||
},
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
|
||||
rules,
|
||||
);
|
||||
// 50 onsite + 50 offshore * (1 + 0) = 100 — no additional effort means hours unchanged
|
||||
expect(result.adjustedHours).toBe(100);
|
||||
});
|
||||
|
||||
it("applies shoring ratio with additional effort factor", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{
|
||||
costMultiplier: 1.0,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: 0.5,
|
||||
additionalEffortRatio: 0.2,
|
||||
},
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 100 },
|
||||
rules,
|
||||
);
|
||||
// onsite = 100 * 0.5 = 50, offshore = 100 * 0.5 * 1.2 = 60 => total = 110
|
||||
expect(result.adjustedHours).toBe(110);
|
||||
});
|
||||
|
||||
it("applies both rate multipliers and shoring together", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, allRules);
|
||||
// Should match exactRule (chapter=Animation, location=India, level=Senior)
|
||||
expect(result.adjustedCostRateCents).toBe(Math.round(10000 * 0.9)); // 9000
|
||||
expect(result.adjustedBillRateCents).toBe(Math.round(15000 * 1.0)); // 15000
|
||||
// shoring: 0.3, additional: 0.1
|
||||
// onsite = 100 * 0.7 = 70, offshore = 100 * 0.3 * 1.1 = 33 => 103
|
||||
expect(result.adjustedHours).toBe(103);
|
||||
expect(result.appliedRules.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("includes rule description in audit trail when present", () => {
|
||||
const result = applyExperienceMultipliers(baseInput, allRules);
|
||||
const descRule = result.appliedRules.find((r) => r.includes("Exact match rule"));
|
||||
expect(descRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("reports no-op when multipliers are 1.0 and no shoring", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ costMultiplier: 1.0, billMultiplier: 1.0 },
|
||||
];
|
||||
const result = applyExperienceMultipliers(
|
||||
{ costRateCents: 5000, billRateCents: 8000, hours: 40 },
|
||||
rules,
|
||||
);
|
||||
expect(result.adjustedCostRateCents).toBe(5000);
|
||||
expect(result.adjustedBillRateCents).toBe(8000);
|
||||
expect(result.adjustedHours).toBe(40);
|
||||
expect(result.appliedRules[0]).toContain("unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyExperienceMultipliersBatch ────────────────────────────────────────
|
||||
|
||||
describe("applyExperienceMultipliersBatch", () => {
|
||||
it("processes multiple inputs and returns correct summary", () => {
|
||||
const rules: ExperienceMultiplierRule[] = [
|
||||
{ chapter: "Animation", costMultiplier: 1.2, billMultiplier: 1.3 },
|
||||
{ costMultiplier: 1.0, billMultiplier: 1.0 }, // global fallback
|
||||
];
|
||||
|
||||
const inputs: RateAdjustmentInput[] = [
|
||||
{ costRateCents: 10000, billRateCents: 15000, hours: 50, chapter: "Animation" },
|
||||
{ costRateCents: 8000, billRateCents: 12000, hours: 30, chapter: "Compositing" },
|
||||
];
|
||||
|
||||
const batch = applyExperienceMultipliersBatch(inputs, rules);
|
||||
expect(batch.results).toHaveLength(2);
|
||||
expect(batch.totalOriginalHours).toBe(80);
|
||||
expect(batch.totalAdjustedHours).toBe(80);
|
||||
// First line adjusted (cost * 1.2), second not (1.0)
|
||||
expect(batch.linesAdjusted).toBe(1);
|
||||
expect(batch.results[0]!.adjustedCostRateCents).toBe(12000);
|
||||
expect(batch.results[1]!.adjustedCostRateCents).toBe(8000);
|
||||
});
|
||||
|
||||
it("handles empty input array", () => {
|
||||
const batch = applyExperienceMultipliersBatch([], [globalRule]);
|
||||
expect(batch.results).toHaveLength(0);
|
||||
expect(batch.totalOriginalHours).toBe(0);
|
||||
expect(batch.totalAdjustedHours).toBe(0);
|
||||
expect(batch.linesAdjusted).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeEvenSpread,
|
||||
getEstimateMonthRange,
|
||||
rebalanceSpread,
|
||||
summarizeMonthlySpread,
|
||||
} from "../estimate/monthly-spread.js";
|
||||
|
||||
describe("getEstimateMonthRange", () => {
|
||||
it("returns months between two dates in the same year", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-03-01"), new Date("2026-06-30")),
|
||||
).toEqual(["2026-03", "2026-04", "2026-05", "2026-06"]);
|
||||
});
|
||||
|
||||
it("returns a single month when start and end are in the same month", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-05-10"), new Date("2026-05-20")),
|
||||
).toEqual(["2026-05"]);
|
||||
});
|
||||
|
||||
it("spans across years", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2025-11-01"), new Date("2026-02-28")),
|
||||
).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]);
|
||||
});
|
||||
|
||||
it("returns empty when endDate < startDate", () => {
|
||||
expect(
|
||||
getEstimateMonthRange(new Date("2026-06-01"), new Date("2026-03-01")),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEvenSpread", () => {
|
||||
it("distributes hours across months weighted by working days", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
// Hours should sum to exactly 100
|
||||
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
|
||||
expect(Math.round(sum * 10) / 10).toBe(100);
|
||||
// Each month should have a positive value
|
||||
for (const month of result.months) {
|
||||
expect(result.spread[month]).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles a single month", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 40,
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-04"]);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
|
||||
it("handles partial first and last months", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 80,
|
||||
startDate: new Date("2026-03-15"),
|
||||
endDate: new Date("2026-04-15"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04"]);
|
||||
const sum = Object.values(result.spread).reduce((a, b) => a + b, 0);
|
||||
expect(Math.round(sum * 10) / 10).toBe(80);
|
||||
// March 15-31 has fewer working days than April 1-15
|
||||
// Both should be positive
|
||||
expect(result.spread["2026-03"]).toBeGreaterThan(0);
|
||||
expect(result.spread["2026-04"]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty spread for empty date range", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-03-01"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual([]);
|
||||
expect(result.spread).toEqual({});
|
||||
});
|
||||
|
||||
it("handles zero total hours", () => {
|
||||
const result = computeEvenSpread({
|
||||
totalHours: 0,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
for (const month of result.months) {
|
||||
expect(result.spread[month]).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebalanceSpread", () => {
|
||||
it("distributes remaining hours after locked months", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-05-31"),
|
||||
lockedMonths: { "2026-03": 50 },
|
||||
});
|
||||
|
||||
expect(result.months).toEqual(["2026-03", "2026-04", "2026-05"]);
|
||||
expect(result.spread["2026-03"]).toBe(50);
|
||||
// Remaining 50 hours across April + May
|
||||
const remaining = (result.spread["2026-04"] ?? 0) + (result.spread["2026-05"] ?? 0);
|
||||
expect(Math.round(remaining * 10) / 10).toBe(50);
|
||||
});
|
||||
|
||||
it("handles all months locked", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 100,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-03": 60, "2026-04": 40 },
|
||||
});
|
||||
|
||||
expect(result.spread["2026-03"]).toBe(60);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps remaining to zero when locked exceeds total", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 50,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-03": 60 },
|
||||
});
|
||||
|
||||
expect(result.spread["2026-03"]).toBe(60);
|
||||
// Remaining is max(0, 50-60) = 0
|
||||
expect(result.spread["2026-04"]).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores locked months outside the date range", () => {
|
||||
const result = rebalanceSpread({
|
||||
totalHours: 80,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
lockedMonths: { "2026-01": 30, "2026-03": 40 },
|
||||
});
|
||||
|
||||
// 2026-01 is outside range, so remaining = 80 - 40 = 40
|
||||
expect(result.spread["2026-03"]).toBe(40);
|
||||
expect(result.spread["2026-04"]).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeMonthlySpread", () => {
|
||||
it("aggregates hours per month across multiple spreads", () => {
|
||||
const result = summarizeMonthlySpread([
|
||||
{ "2026-03": 30, "2026-04": 20 },
|
||||
{ "2026-03": 10, "2026-04": 15, "2026-05": 25 },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"2026-03": 40,
|
||||
"2026-04": 35,
|
||||
"2026-05": 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(summarizeMonthlySpread([])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRecurringDay, getRecurringHoursForDay } from "../allocation/recurrence.js";
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
|
||||
const monday = new Date("2026-03-09"); // Monday
|
||||
const tuesday = new Date("2026-03-10"); // Tuesday
|
||||
const wednesday = new Date("2026-03-11"); // Wednesday
|
||||
const friday = new Date("2026-03-13"); // Friday
|
||||
const nextMonday = new Date("2026-03-16"); // Monday (next week)
|
||||
const twoWeeksMonday = new Date("2026-03-23"); // Monday (two weeks after)
|
||||
|
||||
const allocationStart = new Date("2026-03-09"); // Monday
|
||||
|
||||
describe("isRecurringDay", () => {
|
||||
describe("WEEKLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.WEEKLY,
|
||||
weekdays: [1, 3, 5], // Mon, Wed, Fri
|
||||
};
|
||||
|
||||
it("returns true for days matching weekday list", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(wednesday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(friday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for days not in weekday list", () => {
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BIWEEKLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.BIWEEKLY,
|
||||
weekdays: [1], // Monday only
|
||||
interval: 2,
|
||||
};
|
||||
|
||||
it("returns true on the allocation start week (week 0)", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on the next week (week 1, odd)", () => {
|
||||
expect(isRecurringDay(nextMonday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true two weeks after start (week 2, even)", () => {
|
||||
expect(isRecurringDay(twoWeeksMonday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-matching weekdays even in active weeks", () => {
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MONTHLY", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.MONTHLY,
|
||||
monthDay: 15,
|
||||
};
|
||||
|
||||
it("returns true on the specified day of month", () => {
|
||||
expect(isRecurringDay(new Date("2026-03-15"), pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on other days", () => {
|
||||
expect(isRecurringDay(new Date("2026-03-14"), pattern, allocationStart)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when monthDay is undefined", () => {
|
||||
const noDay: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.MONTHLY,
|
||||
};
|
||||
expect(isRecurringDay(new Date("2026-03-15"), noDay, allocationStart)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CUSTOM", () => {
|
||||
const pattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.CUSTOM,
|
||||
};
|
||||
|
||||
it("always returns true", () => {
|
||||
expect(isRecurringDay(monday, pattern, allocationStart)).toBe(true);
|
||||
expect(isRecurringDay(tuesday, pattern, allocationStart)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecurringHoursForDay", () => {
|
||||
const weeklyPattern: RecurrencePattern = {
|
||||
frequency: RecurrenceFrequency.WEEKLY,
|
||||
weekdays: [1, 3, 5], // Mon, Wed, Fri
|
||||
};
|
||||
|
||||
it("returns default hours on recurring days", () => {
|
||||
expect(getRecurringHoursForDay(monday, weeklyPattern, 8, allocationStart)).toBe(8);
|
||||
});
|
||||
|
||||
it("returns 0 on non-recurring days", () => {
|
||||
expect(getRecurringHoursForDay(tuesday, weeklyPattern, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses pattern.hoursPerDay when set", () => {
|
||||
const patternWithHours: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
hoursPerDay: 4,
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithHours, 8, allocationStart)).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 0 before pattern startDate", () => {
|
||||
const patternWithStart: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-16"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithStart, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 after pattern endDate", () => {
|
||||
const patternWithEnd: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
endDate: new Date("2026-03-08"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithEnd, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns hours within pattern date bounds", () => {
|
||||
const bounded: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, bounded, 8, allocationStart)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { calculateSAH, getDailyHours } from "../sah/calculator.js";
|
||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||
|
||||
const spainRules: SpainScheduleRule = {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
};
|
||||
|
||||
describe("getDailyHours", () => {
|
||||
it("returns base hours when no schedule rules", () => {
|
||||
expect(getDailyHours(new Date("2026-03-10"), 8)).toBe(8); // Monday
|
||||
expect(getDailyHours(new Date("2026-03-13"), 9)).toBe(9); // Thursday
|
||||
});
|
||||
|
||||
it("returns Spain friday hours", () => {
|
||||
// 2026-03-13 is a Friday
|
||||
expect(getDailyHours(new Date("2026-03-13"), 8, spainRules)).toBe(6.5);
|
||||
});
|
||||
|
||||
it("returns Spain summer hours Mon-Thu", () => {
|
||||
// 2026-07-06 is a Monday in summer
|
||||
expect(getDailyHours(new Date("2026-07-06"), 8, spainRules)).toBe(6.5);
|
||||
});
|
||||
|
||||
it("returns Spain regular hours Mon-Thu outside summer", () => {
|
||||
// 2026-03-09 is a Monday, not summer
|
||||
expect(getDailyHours(new Date("2026-03-09"), 8, spainRules)).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSAH", () => {
|
||||
it("calculates basic SAH for a full-time German employee in one week", () => {
|
||||
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — 5 working days, no holidays
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.calendarDays).toBe(5);
|
||||
expect(result.weekendDays).toBe(0);
|
||||
expect(result.grossWorkingDays).toBe(5);
|
||||
expect(result.netWorkingDays).toBe(5);
|
||||
expect(result.standardAvailableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("reduces hours by FTE factor", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 0.5,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.standardAvailableHours).toBe(20);
|
||||
expect(result.effectiveHoursPerDay).toBe(4);
|
||||
});
|
||||
|
||||
it("deducts public holidays", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: ["2026-03-10"], // Tuesday is holiday
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.publicHolidayDays).toBe(1);
|
||||
expect(result.netWorkingDays).toBe(4);
|
||||
expect(result.standardAvailableHours).toBe(32);
|
||||
});
|
||||
|
||||
it("deducts absence days", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: ["2026-03-11", "2026-03-12"], // Wed + Thu
|
||||
});
|
||||
expect(result.absenceDays).toBe(2);
|
||||
expect(result.netWorkingDays).toBe(3);
|
||||
expect(result.standardAvailableHours).toBe(24);
|
||||
});
|
||||
|
||||
it("handles weekends correctly", () => {
|
||||
// 2026-03-07 (Sat) to 2026-03-13 (Fri) — includes a full weekend
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-07"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.calendarDays).toBe(7);
|
||||
expect(result.weekendDays).toBe(2);
|
||||
expect(result.grossWorkingDays).toBe(5);
|
||||
expect(result.netWorkingDays).toBe(5);
|
||||
expect(result.standardAvailableHours).toBe(40);
|
||||
});
|
||||
|
||||
it("uses 9h for India", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 9,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
expect(result.standardAvailableHours).toBe(45);
|
||||
});
|
||||
|
||||
it("handles Spain variable schedule in summer", () => {
|
||||
// 2026-07-06 (Mon) to 2026-07-10 (Fri) — all summer, includes Friday
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-07-06"),
|
||||
periodEnd: new Date("2026-07-10"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// Mon-Fri all summer: 6.5 * 5 = 32.5
|
||||
expect(result.standardAvailableHours).toBe(32.5);
|
||||
});
|
||||
|
||||
it("handles Spain variable schedule outside summer", () => {
|
||||
// 2026-03-09 (Mon) to 2026-03-13 (Fri) — non-summer
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 1.0,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// Mon-Thu: 9h each = 36, Fri: 6.5h = total 42.5
|
||||
expect(result.standardAvailableHours).toBe(42.5);
|
||||
});
|
||||
|
||||
it("combines FTE with Spain schedule", () => {
|
||||
const result = calculateSAH({
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: spainRules,
|
||||
fte: 0.5,
|
||||
periodStart: new Date("2026-03-09"),
|
||||
periodEnd: new Date("2026-03-13"),
|
||||
publicHolidays: [],
|
||||
absenceDays: [],
|
||||
});
|
||||
// (9*4 + 6.5*1) * 0.5 = 42.5 * 0.5 = 21.25
|
||||
expect(result.standardAvailableHours).toBe(21.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { calculateAllocation } from "../allocation/calculator.js";
|
||||
import { validateAvailability } from "../allocation/availability-validator.js";
|
||||
|
||||
// Resource available Mon–Sat (6h Saturday)
|
||||
const satAvailability = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 6,
|
||||
};
|
||||
|
||||
// Week Mon 2025-01-06 to Sat 2025-01-11
|
||||
const MON = new Date("2025-01-06");
|
||||
const SAT = new Date("2025-01-11");
|
||||
|
||||
describe("calculateAllocation — includeSaturday", () => {
|
||||
it("excludes Saturday by default when resource has saturday availability", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
// includeSaturday not set → defaults to false
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5); // Mon–Fri only
|
||||
expect(result.totalHours).toBe(40);
|
||||
});
|
||||
|
||||
it("includes Saturday when includeSaturday is true", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
includeSaturday: true,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(6); // Mon–Sat
|
||||
// Saturday capped at 6h (resource availability)
|
||||
expect(result.totalHours).toBe(46); // 5×8 + 6
|
||||
expect(result.totalCostCents).toBe(460000); // 46h × 10000 cents/h
|
||||
});
|
||||
|
||||
it("includeSaturday: false explicitly still excludes Saturday", () => {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: satAvailability,
|
||||
includeSaturday: false,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5);
|
||||
});
|
||||
|
||||
it("does not add Saturday if resource has no saturday availability", () => {
|
||||
const noSatAvailability = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 };
|
||||
const result = calculateAllocation({
|
||||
lcrCents: 10000,
|
||||
hoursPerDay: 8,
|
||||
startDate: MON,
|
||||
endDate: SAT,
|
||||
availability: noSatAvailability,
|
||||
includeSaturday: true,
|
||||
});
|
||||
|
||||
expect(result.workingDays).toBe(5); // Saturday still 0 because resource unavailable
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAvailability — includeSaturday", () => {
|
||||
it("skips Saturday conflicts when includeSaturday is false", () => {
|
||||
// Resource available only on Saturday (Mon–Fri: 0)
|
||||
const satOnlyAvail = { monday: 0, tuesday: 0, wednesday: 0, thursday: 0, friday: 0, saturday: 8 };
|
||||
const result = validateAvailability(SAT, SAT, 8, satOnlyAvail, [], false);
|
||||
|
||||
// Saturday is excluded, so no conflicts (day treated as non-working)
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.totalConflictDays).toBe(0);
|
||||
});
|
||||
|
||||
it("detects conflict on Saturday when includeSaturday is true", () => {
|
||||
const result = validateAvailability(
|
||||
SAT,
|
||||
SAT,
|
||||
8, // request 8h
|
||||
satAvailability, // Saturday: 6h available
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
// 8h requested > 6h available → conflict
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.totalConflictDays).toBe(1);
|
||||
expect(result.conflicts[0]?.overageHours).toBe(2);
|
||||
});
|
||||
|
||||
it("no conflict on Saturday within availability when includeSaturday true", () => {
|
||||
const result = validateAvailability(SAT, SAT, 4, satAvailability, [], true);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isVacationDay, getVacationDatesInRange } from "../vacation/utils.js";
|
||||
import type { VacationRange } from "../vacation/utils.js";
|
||||
|
||||
const approved = (start: string, end: string): VacationRange => ({
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
status: "APPROVED",
|
||||
});
|
||||
|
||||
const pending = (start: string, end: string): VacationRange => ({
|
||||
startDate: new Date(start),
|
||||
endDate: new Date(end),
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
describe("isVacationDay", () => {
|
||||
it("returns true when date falls within an approved vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the start date of the vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-10"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the end date of the vacation", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-14"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when date is outside vacation range", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-09"), vacations)).toBe(false);
|
||||
expect(isVacationDay(new Date("2026-03-15"), vacations)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-APPROVED vacations", () => {
|
||||
const vacations = [pending("2026-03-10", "2026-03-14")];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty vacation array", () => {
|
||||
expect(isVacationDay(new Date("2026-03-12"), [])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles multiple overlapping vacations", () => {
|
||||
const vacations = [
|
||||
approved("2026-03-10", "2026-03-12"),
|
||||
approved("2026-03-11", "2026-03-14"),
|
||||
];
|
||||
expect(isVacationDay(new Date("2026-03-11"), vacations)).toBe(true);
|
||||
expect(isVacationDay(new Date("2026-03-13"), vacations)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles mixed statuses correctly", () => {
|
||||
const vacations = [
|
||||
pending("2026-03-10", "2026-03-14"),
|
||||
approved("2026-03-20", "2026-03-21"),
|
||||
];
|
||||
expect(isVacationDay(new Date("2026-03-12"), vacations)).toBe(false);
|
||||
expect(isVacationDay(new Date("2026-03-20"), vacations)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVacationDatesInRange", () => {
|
||||
it("returns approved vacation dates within range", () => {
|
||||
const vacations = [approved("2026-03-10", "2026-03-12")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-09"),
|
||||
new Date("2026-03-13"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(3); // 10, 11, 12
|
||||
expect(dates[0]!.getDate()).toBe(10);
|
||||
expect(dates[2]!.getDate()).toBe(12);
|
||||
});
|
||||
|
||||
it("clips to the query range", () => {
|
||||
const vacations = [approved("2026-03-05", "2026-03-15")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-10"),
|
||||
new Date("2026-03-12"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(3); // 10, 11, 12
|
||||
});
|
||||
|
||||
it("returns empty for no approved vacations", () => {
|
||||
const vacations = [pending("2026-03-10", "2026-03-14")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiple non-overlapping vacations", () => {
|
||||
const vacations = [
|
||||
approved("2026-03-10", "2026-03-11"),
|
||||
approved("2026-03-20", "2026-03-21"),
|
||||
];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toHaveLength(4); // 10, 11, 20, 21
|
||||
});
|
||||
|
||||
it("returns empty when range does not intersect vacations", () => {
|
||||
const vacations = [approved("2026-04-01", "2026-04-05")];
|
||||
const dates = getVacationDatesInRange(
|
||||
new Date("2026-03-01"),
|
||||
new Date("2026-03-31"),
|
||||
vacations,
|
||||
);
|
||||
expect(dates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
} from "../estimate/index.js";
|
||||
|
||||
function makeLine(overrides: Partial<VersionCompareInput["demandLines"][number]> & { id: string; name: string }) {
|
||||
return {
|
||||
hours: 0,
|
||||
costRateCents: 0,
|
||||
billRateCents: 0,
|
||||
costTotalCents: 0,
|
||||
priceTotalCents: 0,
|
||||
lineType: "ROLE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeVersion(overrides: Partial<VersionCompareInput> = {}): VersionCompareInput {
|
||||
return {
|
||||
versionNumber: 1,
|
||||
demandLines: [],
|
||||
assumptions: [],
|
||||
scopeItems: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compareEstimateVersions", () => {
|
||||
it("returns zeroed diff for identical versions", () => {
|
||||
const lines = [
|
||||
makeLine({ id: "l1", name: "Designer", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 }),
|
||||
];
|
||||
const assumptions = [{ key: "rate", label: "Day rate", value: 500 }];
|
||||
const scopeItems = [{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }];
|
||||
|
||||
const v = makeVersion({ demandLines: lines, assumptions, scopeItems });
|
||||
const diff = compareEstimateVersions(v, v);
|
||||
|
||||
expect(diff.summary.totalHoursDelta).toBe(0);
|
||||
expect(diff.summary.totalCostDelta).toBe(0);
|
||||
expect(diff.summary.totalPriceDelta).toBe(0);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.linesChanged).toBe(0);
|
||||
expect(diff.summary.assumptionsChanged).toBe(0);
|
||||
expect(diff.summary.scopeItemsAdded).toBe(0);
|
||||
expect(diff.summary.scopeItemsRemoved).toBe(0);
|
||||
|
||||
expect(diff.demandLineDiffs).toHaveLength(1);
|
||||
expect(diff.demandLineDiffs[0]!.status).toBe("unchanged");
|
||||
|
||||
expect(diff.assumptionDiffs).toHaveLength(1);
|
||||
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("detects added demand lines", () => {
|
||||
const a = makeVersion({ demandLines: [] });
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "Animator", hours: 40, costTotalCents: 200_00, priceTotalCents: 320_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(1);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.totalHoursDelta).toBe(40);
|
||||
expect(diff.summary.totalCostDelta).toBe(200_00);
|
||||
expect(diff.summary.totalPriceDelta).toBe(320_00);
|
||||
|
||||
expect(diff.demandLineDiffs).toHaveLength(1);
|
||||
expect(diff.demandLineDiffs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Animator",
|
||||
status: "added",
|
||||
hoursDelta: 40,
|
||||
costDelta: 200_00,
|
||||
priceDelta: 320_00,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects removed demand lines", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "Lighter", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({ demandLines: [] });
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(1);
|
||||
expect(diff.summary.totalHoursDelta).toBe(-20);
|
||||
|
||||
expect(diff.demandLineDiffs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Lighter",
|
||||
status: "removed",
|
||||
hoursDelta: -20,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects changed hours and rates", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "TD", hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "l1", name: "TD", hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesChanged).toBe(1);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.totalHoursDelta).toBe(30);
|
||||
expect(diff.summary.totalCostDelta).toBe(180_00);
|
||||
expect(diff.summary.totalPriceDelta).toBe(300_00);
|
||||
|
||||
const lineDiff = diff.demandLineDiffs[0]!;
|
||||
expect(lineDiff.status).toBe("changed");
|
||||
expect(lineDiff.hoursDelta).toBe(30);
|
||||
expect(lineDiff.costDelta).toBe(180_00);
|
||||
expect(lineDiff.priceDelta).toBe(300_00);
|
||||
expect(lineDiff.a).toEqual({ hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 });
|
||||
expect(lineDiff.b).toEqual({ hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 });
|
||||
});
|
||||
|
||||
it("fuzzy-matches demand lines by name+lineType when ids differ", () => {
|
||||
const a = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "old-id", name: "Compositor", lineType: "ROLE", hours: 10, costTotalCents: 50_00, priceTotalCents: 80_00 }),
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "new-id", name: "Compositor", lineType: "ROLE", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }),
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.summary.linesRemoved).toBe(0);
|
||||
expect(diff.summary.linesChanged).toBe(1);
|
||||
expect(diff.demandLineDiffs[0]!.hoursDelta).toBe(10);
|
||||
});
|
||||
|
||||
it("detects added and removed assumptions", () => {
|
||||
const a = makeVersion({
|
||||
assumptions: [
|
||||
{ key: "overhead", label: "Overhead %", value: 15 },
|
||||
{ key: "old_key", label: "Old", value: "x" },
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
assumptions: [
|
||||
{ key: "overhead", label: "Overhead %", value: 20 },
|
||||
{ key: "new_key", label: "New", value: "y" },
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.assumptionsChanged).toBe(3);
|
||||
|
||||
const overheadDiff = diff.assumptionDiffs.find((d) => d.key === "overhead")!;
|
||||
expect(overheadDiff.status).toBe("changed");
|
||||
expect(overheadDiff.aValue).toBe(15);
|
||||
expect(overheadDiff.bValue).toBe(20);
|
||||
|
||||
expect(diff.assumptionDiffs.find((d) => d.key === "old_key")!.status).toBe("removed");
|
||||
expect(diff.assumptionDiffs.find((d) => d.key === "new_key")!.status).toBe("added");
|
||||
});
|
||||
|
||||
it("detects scope item additions and removals", () => {
|
||||
const a = makeVersion({
|
||||
scopeItems: [
|
||||
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
|
||||
{ name: "Asset X", sequenceNo: 2, scopeType: "ASSET" },
|
||||
],
|
||||
});
|
||||
const b = makeVersion({
|
||||
scopeItems: [
|
||||
{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" },
|
||||
{ name: "Shot B", sequenceNo: 2, scopeType: "SHOT" },
|
||||
],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
|
||||
expect(diff.summary.scopeItemsAdded).toBe(1);
|
||||
expect(diff.summary.scopeItemsRemoved).toBe(1);
|
||||
});
|
||||
|
||||
it("handles empty versions", () => {
|
||||
const diff = compareEstimateVersions(makeVersion(), makeVersion());
|
||||
|
||||
expect(diff.summary.totalHoursDelta).toBe(0);
|
||||
expect(diff.summary.linesAdded).toBe(0);
|
||||
expect(diff.demandLineDiffs).toHaveLength(0);
|
||||
expect(diff.assumptionDiffs).toHaveLength(0);
|
||||
expect(diff.scopeItemDiffs).toHaveLength(0);
|
||||
expect(diff.resourceSnapshotDiffs).toHaveLength(0);
|
||||
expect(diff.chapterSubtotals).toHaveLength(0);
|
||||
expect(diff.summary.marginPercentA).toBe(0);
|
||||
expect(diff.summary.marginPercentB).toBe(0);
|
||||
});
|
||||
|
||||
it("compares complex assumption values by deep equality", () => {
|
||||
const a = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
|
||||
});
|
||||
const b = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }],
|
||||
});
|
||||
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
expect(diff.assumptionDiffs[0]!.status).toBe("unchanged");
|
||||
|
||||
const b2 = makeVersion({
|
||||
assumptions: [{ key: "config", label: "Config", value: { fps: 30, resolution: "4K" } }],
|
||||
});
|
||||
const diff2 = compareEstimateVersions(a, b2);
|
||||
expect(diff2.assumptionDiffs[0]!.status).toBe("changed");
|
||||
});
|
||||
|
||||
describe("scope item diffs (detailed)", () => {
|
||||
it("detects changed scope item fields", () => {
|
||||
const a = makeVersion({
|
||||
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 120 }],
|
||||
});
|
||||
const b = makeVersion({
|
||||
scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 200 }],
|
||||
});
|
||||
const diff = compareEstimateVersions(a, b);
|
||||
expect(diff.scopeItemDiffs).toHaveLength(1);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("frameCount");
|
||||
expect(diff.summary.scopeItemsChanged).toBe(1);
|
||||
});
|
||||
|
||||
it("detects description changes in scope items", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "Old" }] }),
|
||||
makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "New" }] }),
|
||||
);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.scopeItemDiffs[0]!.changedFields).toContain("description");
|
||||
});
|
||||
|
||||
it("marks unchanged scope items", () => {
|
||||
const scope = { name: "Asset Hero", sequenceNo: 1, scopeType: "ASSET", frameCount: 50 };
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({ scopeItems: [scope] }),
|
||||
makeVersion({ scopeItems: [scope] }),
|
||||
);
|
||||
expect(diff.scopeItemDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resource snapshot diffs", () => {
|
||||
it("detects added resources", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion(),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ displayName: "John", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs).toHaveLength(1);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("added");
|
||||
expect(diff.summary.resourceSnapshotsChanged).toBe(1);
|
||||
});
|
||||
|
||||
it("detects rate changes", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5500, ucrCents: 8500 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
|
||||
expect(diff.resourceSnapshotDiffs[0]!.lcrDelta).toBe(500);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.ucrDelta).toBe(500);
|
||||
});
|
||||
|
||||
it("detects removed resources", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ displayName: "Max", currency: "EUR", lcrCents: 4000, ucrCents: 7000 }],
|
||||
}),
|
||||
makeVersion(),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("removed");
|
||||
});
|
||||
|
||||
it("matches by resourceId for unchanged rates", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Old Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "New Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("detects location changes", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Munich" }],
|
||||
}),
|
||||
makeVersion({
|
||||
resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Berlin" }],
|
||||
}),
|
||||
);
|
||||
expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapter subtotals", () => {
|
||||
it("groups demand lines by chapter and computes deltas", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "Anim Lead", hours: 80, costTotalCents: 480_00, priceTotalCents: 720_00, chapter: "Animation" }),
|
||||
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
|
||||
],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "Anim Lead", hours: 100, costTotalCents: 600_00, priceTotalCents: 900_00, chapter: "Animation" }),
|
||||
makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(diff.chapterSubtotals).toHaveLength(2);
|
||||
const anim = diff.chapterSubtotals.find((c) => c.chapter === "Animation")!;
|
||||
expect(anim.hoursDelta).toBe(20);
|
||||
expect(anim.costDelta).toBe(120_00);
|
||||
});
|
||||
|
||||
it("uses (no chapter) fallback", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "Misc", hours: 10, costTotalCents: 30_00, priceTotalCents: 50_00 })],
|
||||
}),
|
||||
makeVersion(),
|
||||
);
|
||||
expect(diff.chapterSubtotals[0]!.chapter).toBe("(no chapter)");
|
||||
});
|
||||
|
||||
it("sorts by absolute cost delta descending", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "A", hours: 10, costTotalCents: 100_00, priceTotalCents: 200_00, chapter: "Small" }),
|
||||
makeLine({ id: "d2", name: "B", hours: 100, costTotalCents: 5000_00, priceTotalCents: 8000_00, chapter: "Big" }),
|
||||
],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [
|
||||
makeLine({ id: "d1", name: "A", hours: 12, costTotalCents: 120_00, priceTotalCents: 240_00, chapter: "Small" }),
|
||||
makeLine({ id: "d2", name: "B", hours: 200, costTotalCents: 10000_00, priceTotalCents: 16000_00, chapter: "Big" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(diff.chapterSubtotals[0]!.chapter).toBe("Big");
|
||||
});
|
||||
});
|
||||
|
||||
describe("margin calculation", () => {
|
||||
it("computes margin percent and delta", () => {
|
||||
const diff = compareEstimateVersions(
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 })],
|
||||
}),
|
||||
makeVersion({
|
||||
demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 1000_00 })],
|
||||
}),
|
||||
);
|
||||
// A: (80000-50000)/80000 = 37.5%
|
||||
expect(diff.summary.marginPercentA).toBeCloseTo(37.5, 1);
|
||||
// B: (100000-50000)/100000 = 50%
|
||||
expect(diff.summary.marginPercentB).toBeCloseTo(50, 1);
|
||||
expect(diff.summary.marginPercentDelta).toBeCloseTo(12.5, 1);
|
||||
});
|
||||
|
||||
it("handles zero price gracefully", () => {
|
||||
const diff = compareEstimateVersions(makeVersion(), makeVersion());
|
||||
expect(diff.summary.marginPercentA).toBe(0);
|
||||
expect(diff.summary.marginPercentB).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
generateWeekRange,
|
||||
distributeHoursToWeeks,
|
||||
weekKeyFromDate,
|
||||
aggregateWeeklyToMonthly,
|
||||
aggregateWeeklyByChapter,
|
||||
} from "../estimate/weekly-phasing.js";
|
||||
|
||||
describe("generateWeekRange", () => {
|
||||
it("returns a single week for a range within one week", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-03-20"); // Mon-Fri of W12
|
||||
expect(weeks).toHaveLength(1);
|
||||
expect(weeks[0]!.weekNumber).toBe(12);
|
||||
expect(weeks[0]!.year).toBe(2026);
|
||||
expect(weeks[0]!.label).toBe("W12 2026");
|
||||
});
|
||||
|
||||
it("returns multiple weeks for a multi-week range", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-04-10");
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(3);
|
||||
// Should cover W12 through at least W15
|
||||
const weekNumbers = weeks.map((w) => w.weekNumber);
|
||||
expect(weekNumbers).toContain(12);
|
||||
expect(weekNumbers).toContain(13);
|
||||
expect(weekNumbers).toContain(14);
|
||||
});
|
||||
|
||||
it("handles month boundary crossing", () => {
|
||||
const weeks = generateWeekRange("2026-03-28", "2026-04-05");
|
||||
// This spans end of March into April
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(1);
|
||||
// Each week has proper start/end dates
|
||||
for (const week of weeks) {
|
||||
expect(week.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(week.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles year boundary crossing", () => {
|
||||
const weeks = generateWeekRange("2025-12-22", "2026-01-11");
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(2);
|
||||
// Should include weeks in both years (ISO week year may differ from calendar year)
|
||||
const labels = weeks.map((w) => w.label);
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns correct ISO week numbers and labels", () => {
|
||||
const weeks = generateWeekRange("2026-01-05", "2026-01-18");
|
||||
for (const week of weeks) {
|
||||
expect(week.label).toMatch(/^W\d{2} \d{4}$/);
|
||||
expect(week.weekNumber).toBeGreaterThanOrEqual(1);
|
||||
expect(week.weekNumber).toBeLessThanOrEqual(53);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array when endDate < startDate", () => {
|
||||
const weeks = generateWeekRange("2026-06-01", "2026-03-01");
|
||||
expect(weeks).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns week definitions with Monday start and Sunday end", () => {
|
||||
const weeks = generateWeekRange("2026-03-16", "2026-03-22");
|
||||
const week = weeks[0]!;
|
||||
const start = new Date(week.startDate);
|
||||
const end = new Date(week.endDate);
|
||||
expect(start.getUTCDay()).toBe(1); // Monday
|
||||
expect(end.getUTCDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeHoursToWeeks", () => {
|
||||
it("distributes hours evenly across weeks", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-10",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks.length).toBeGreaterThan(0);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
// All weeks should have roughly equal hours
|
||||
const values = Object.values(result.weeklyHours);
|
||||
const avg = 100 / values.length;
|
||||
for (const h of values) {
|
||||
expect(h).toBeCloseTo(avg, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("distributes 100h over 4 weeks = 25h each", () => {
|
||||
// Find a 4-week range
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-12",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(4);
|
||||
expect(result.totalDistributedHours).toBe(100);
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(25);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles even distribution with remainder", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(3);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("front loaded: first half weeks have more hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-05-10",
|
||||
pattern: "front_loaded",
|
||||
});
|
||||
|
||||
const values = Object.values(result.weeklyHours);
|
||||
expect(values.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// First value should be greater than last value
|
||||
expect(values[0]!).toBeGreaterThan(values[values.length - 1]!);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("back loaded: last half weeks have more hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-05-10",
|
||||
pattern: "back_loaded",
|
||||
});
|
||||
|
||||
const values = Object.values(result.weeklyHours);
|
||||
expect(values.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Last value should be greater than first value
|
||||
expect(values[values.length - 1]!).toBeGreaterThan(values[0]!);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("custom: uses provided hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "custom",
|
||||
customWeeklyHours: {
|
||||
"2026-W12": 40,
|
||||
"2026-W13": 35,
|
||||
"2026-W14": 25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.weeklyHours["2026-W12"]).toBe(40);
|
||||
expect(result.weeklyHours["2026-W13"]).toBe(35);
|
||||
expect(result.weeklyHours["2026-W14"]).toBe(25);
|
||||
});
|
||||
|
||||
it("returns all zeros for zero hours", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 0,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-05",
|
||||
pattern: "even",
|
||||
});
|
||||
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(0);
|
||||
}
|
||||
expect(result.totalDistributedHours).toBe(0);
|
||||
});
|
||||
|
||||
it("handles single week range", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 40,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-03-20",
|
||||
pattern: "front_loaded",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(1);
|
||||
expect(result.totalDistributedHours).toBe(40);
|
||||
});
|
||||
|
||||
it("returns empty result for empty range", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 100,
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-03-01",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(0);
|
||||
expect(result.totalDistributedHours).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to even pattern when not specified", () => {
|
||||
const result = distributeHoursToWeeks({
|
||||
totalHours: 80,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-04-12",
|
||||
});
|
||||
|
||||
expect(result.weeks).toHaveLength(4);
|
||||
expect(result.totalDistributedHours).toBeCloseTo(80, 1);
|
||||
// Should be even
|
||||
for (const h of Object.values(result.weeklyHours)) {
|
||||
expect(h).toBe(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekKeyFromDate", () => {
|
||||
it("returns correct week key for a normal date", () => {
|
||||
// March 18, 2026 is a Wednesday in W12
|
||||
expect(weekKeyFromDate("2026-03-18")).toBe("2026-W12");
|
||||
});
|
||||
|
||||
it("handles Date object input", () => {
|
||||
const date = new Date("2026-03-18");
|
||||
expect(weekKeyFromDate(date)).toBe("2026-W12");
|
||||
});
|
||||
|
||||
it("handles year boundary (Dec 31 may be W01 of next year)", () => {
|
||||
// Dec 31, 2026 is a Thursday. Let's check ISO week.
|
||||
const key = weekKeyFromDate("2026-12-31");
|
||||
// ISO week 53 or W01 of 2027 depending on the year
|
||||
expect(key).toMatch(/^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
it("handles Jan 1 which may be in previous year's last week", () => {
|
||||
// Jan 1, 2027 is a Friday
|
||||
const key = weekKeyFromDate("2027-01-01");
|
||||
expect(key).toMatch(/^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
it("returns W01 for the first Monday of a standard year", () => {
|
||||
// In 2026, Jan 5 is a Monday, should be W02 (Jan 1 is Thursday, W01 starts Dec 29)
|
||||
// Actually let's verify: Jan 1 2026 is Thursday → W01 starts Mon Dec 29 2025
|
||||
// Jan 5 2026 is Monday → W02
|
||||
expect(weekKeyFromDate("2026-01-05")).toBe("2026-W02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWeeklyToMonthly", () => {
|
||||
it("converts week keys to month keys and sums hours", () => {
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W12": 40,
|
||||
"2026-W13": 40,
|
||||
"2026-W14": 40,
|
||||
"2026-W15": 40,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
// All these weeks are in March-April 2026
|
||||
const totalMonthly = Object.values(monthly).reduce((sum, h) => sum + h, 0);
|
||||
expect(totalMonthly).toBeCloseTo(160, 1);
|
||||
});
|
||||
|
||||
it("sums hours within the same month", () => {
|
||||
// W10 and W11 of 2026 are both in March
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W10": 20,
|
||||
"2026-W11": 30,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
// Both weeks' Thursdays fall in March
|
||||
expect(monthly["2026-03"]).toBe(50);
|
||||
});
|
||||
|
||||
it("handles cross-month weeks correctly", () => {
|
||||
// W14 of 2026: Thursday April 2 → April
|
||||
const weeklyHours: Record<string, number> = {
|
||||
"2026-W13": 25,
|
||||
"2026-W14": 25,
|
||||
};
|
||||
|
||||
const monthly = aggregateWeeklyToMonthly(weeklyHours);
|
||||
const total = Object.values(monthly).reduce((sum, h) => sum + h, 0);
|
||||
expect(total).toBe(50);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(aggregateWeeklyToMonthly({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWeeklyByChapter", () => {
|
||||
it("groups lines by chapter and sums hours per week", () => {
|
||||
const lines = [
|
||||
{ chapter: "Animation", weeklyHours: { "2026-W12": 20, "2026-W13": 15 } },
|
||||
{ chapter: "Animation", weeklyHours: { "2026-W12": 10, "2026-W13": 5 } },
|
||||
{ chapter: "Lighting", weeklyHours: { "2026-W12": 8, "2026-W13": 12 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["Animation"]!["2026-W12"]).toBe(30);
|
||||
expect(result["Animation"]!["2026-W13"]).toBe(20);
|
||||
expect(result["Lighting"]!["2026-W12"]).toBe(8);
|
||||
expect(result["Lighting"]!["2026-W13"]).toBe(12);
|
||||
});
|
||||
|
||||
it("handles null chapter as (Unassigned)", () => {
|
||||
const lines = [
|
||||
{ chapter: null, weeklyHours: { "2026-W12": 10 } },
|
||||
{ weeklyHours: { "2026-W12": 5 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["(Unassigned)"]!["2026-W12"]).toBe(15);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(aggregateWeeklyByChapter([])).toEqual({});
|
||||
});
|
||||
|
||||
it("handles lines with no overlapping weeks", () => {
|
||||
const lines = [
|
||||
{ chapter: "FX", weeklyHours: { "2026-W12": 10 } },
|
||||
{ chapter: "FX", weeklyHours: { "2026-W15": 20 } },
|
||||
];
|
||||
|
||||
const result = aggregateWeeklyByChapter(lines);
|
||||
|
||||
expect(result["FX"]!["2026-W12"]).toBe(10);
|
||||
expect(result["FX"]!["2026-W15"]).toBe(20);
|
||||
expect(result["FX"]!["2026-W13"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user