test(scenario): add unit regression coverage for all four scenario modules
Previously untested business logic — no direct tests existed for the
scenario domain beyond auth guards and delegation stubs.
scenario-shared.test.ts (13 tests)
- roundToTenths: rounding edge cases
- getScenarioAvailability: null/undefined fall through to DEFAULT_AVAILABILITY
- collectScenarioSkillSet: null, empty, lowercase, dedup, whitespace filter
- calculateScenarioEntryHours: null resourceId → calculateAllocation,
non-null → calculateEffectiveBookedHours with context map lookup
scenario-apply.test.ts (6 tests)
- NOT_FOUND guard
- remove:true → CANCELLED status, not counted in appliedCount
- assignmentId without remove → update branch, appliedCount 1
- no assignmentId / no resourceId → skipped, appliedCount 0
- resourceId only → create with computed dailyCostCents (lcrCents × hours)
- mixed changes → correct aggregate appliedCount
scenario-baseline.test.ts (6 tests)
- NOT_FOUND guard
- empty project → zeroed totals
- costCents computed from lcrCents × effective hours
- CANCELLED assignments excluded via findMany WHERE filter
- demands mapped with headcount and roleName from roleEntity
- totalCostCents is sum of all assignment costCents
scenario-simulation.test.ts (6 tests)
- NOT_FOUND guard
- unchanged carry-through → delta.headcount 0
- remove change → delta.headcount -1
- new resource → delta.headcount +1
- budget exceeded → warnings includes /exceeds budget/i
- skill coverage → delta.skillCoveragePct > 0 when scenario adds skills
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { applyProjectScenario } from "../router/scenario-apply.js";
|
||||||
|
|
||||||
|
vi.mock("../lib/audit.js", () => ({
|
||||||
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeDb(overrides: {
|
||||||
|
projectFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
assignmentUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
assignmentCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
resourceFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
update: overrides.assignmentUpdate ?? vi.fn().mockResolvedValue({}),
|
||||||
|
create: overrides.assignmentCreate ?? vi.fn().mockResolvedValue({ id: "new_assignment_1" }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }),
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseChange = {
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("applyProjectScenario", () => {
|
||||||
|
let assignmentUpdate: ReturnType<typeof vi.fn>;
|
||||||
|
let assignmentCreate: ReturnType<typeof vi.fn>;
|
||||||
|
let resourceFindUnique: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
assignmentUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
assignmentCreate = vi.fn().mockResolvedValue({ id: "new_assignment_1" });
|
||||||
|
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NOT_FOUND when the project does not exist", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
projectFindUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
applyProjectScenario(db, { projectId: "missing_project", changes: [] }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an assignment when remove:true is supplied", async () => {
|
||||||
|
const db = makeDb({ assignmentUpdate });
|
||||||
|
|
||||||
|
const result = await applyProjectScenario(db, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{ ...baseChange, assignmentId: "assignment_1", remove: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assignmentUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: "assignment_1" },
|
||||||
|
data: { status: "CANCELLED" },
|
||||||
|
});
|
||||||
|
// Cancels continue early without pushing into `created`, so appliedCount is 0
|
||||||
|
expect(result.appliedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates dates and hours when assignmentId is present without remove", async () => {
|
||||||
|
const db = makeDb({ assignmentUpdate });
|
||||||
|
|
||||||
|
const result = await applyProjectScenario(db, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{ ...baseChange, assignmentId: "assignment_1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assignmentUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: "assignment_1" },
|
||||||
|
data: {
|
||||||
|
startDate: baseChange.startDate,
|
||||||
|
endDate: baseChange.endDate,
|
||||||
|
hoursPerDay: baseChange.hoursPerDay,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.appliedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a change that has neither assignmentId nor resourceId", async () => {
|
||||||
|
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
||||||
|
|
||||||
|
const result = await applyProjectScenario(db, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{ ...baseChange }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assignmentCreate).not.toHaveBeenCalled();
|
||||||
|
expect(result.appliedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new assignment when only resourceId is present and computes dailyCostCents", async () => {
|
||||||
|
resourceFindUnique = vi.fn().mockResolvedValue({ lcrCents: 200 });
|
||||||
|
const db = makeDb({ assignmentCreate, resourceFindUnique });
|
||||||
|
|
||||||
|
const result = await applyProjectScenario(db, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [{ ...baseChange, resourceId: "resource_1", hoursPerDay: 6 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resourceFindUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "resource_1" },
|
||||||
|
select: { lcrCents: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// dailyCostCents = Math.round(lcrCents * hoursPerDay) = Math.round(200 * 6) = 1200
|
||||||
|
expect(assignmentCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
hoursPerDay: 6,
|
||||||
|
dailyCostCents: 1200,
|
||||||
|
status: "PROPOSED",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(result.appliedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts correctly across multiple mixed changes", async () => {
|
||||||
|
assignmentCreate = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ id: "new_1" })
|
||||||
|
.mockResolvedValueOnce({ id: "new_2" });
|
||||||
|
assignmentUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique });
|
||||||
|
|
||||||
|
const result = await applyProjectScenario(db, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [
|
||||||
|
// create 1
|
||||||
|
{ ...baseChange, resourceId: "resource_1" },
|
||||||
|
// create 2
|
||||||
|
{ ...baseChange, resourceId: "resource_2" },
|
||||||
|
// cancel
|
||||||
|
{ ...baseChange, assignmentId: "assignment_1", remove: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assignmentCreate).toHaveBeenCalledTimes(2);
|
||||||
|
// Cancels continue early without pushing into `created`.
|
||||||
|
// appliedCount = 2 creates + 0 cancel = 2.
|
||||||
|
expect(result.appliedCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { readProjectScenarioBaseline } from "../router/scenario-baseline.js";
|
||||||
|
|
||||||
|
vi.mock("../lib/resource-capacity.js", () => ({
|
||||||
|
calculateEffectiveAvailableHours: vi.fn().mockReturnValue(160),
|
||||||
|
calculateEffectiveBookedHours: vi.fn().mockReturnValue(0),
|
||||||
|
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DEFAULT_AVAILABILITY = {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Test Project",
|
||||||
|
shortCode: "TP-001",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
budgetCents: 100_000_00,
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeResource(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Alice Tester",
|
||||||
|
eid: "EMP-001",
|
||||||
|
lcrCents: 10000,
|
||||||
|
availability: DEFAULT_AVAILABILITY,
|
||||||
|
countryId: "DE",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
skills: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAssignment(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "assignment_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
status: "ACTIVE",
|
||||||
|
roleId: "role_1",
|
||||||
|
role: "Developer",
|
||||||
|
resource: makeResource(),
|
||||||
|
roleEntity: { id: "role_1", name: "Developer", color: "#0000ff" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDemand(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "demand_1",
|
||||||
|
roleId: "role_1",
|
||||||
|
role: "Designer",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
headcount: 2,
|
||||||
|
status: "OPEN",
|
||||||
|
roleEntity: { id: "role_1", name: "Designer", color: "#ff0000" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSET = Symbol("unset");
|
||||||
|
|
||||||
|
function makeDb(overrides: {
|
||||||
|
project?: unknown;
|
||||||
|
assignments?: unknown[];
|
||||||
|
demands?: unknown[];
|
||||||
|
} = {}) {
|
||||||
|
const projectValue = "project" in overrides ? overrides.project : baseProject;
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(projectValue),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(overrides.assignments ?? []),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(overrides.demands ?? []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void UNSET;
|
||||||
|
|
||||||
|
describe("readProjectScenarioBaseline", () => {
|
||||||
|
it("throws NOT_FOUND when project does not exist", async () => {
|
||||||
|
const db = makeDb({ project: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readProjectScenarioBaseline(db as never, "project_missing"),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Project not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zeroed totals when no assignments and no demands exist", async () => {
|
||||||
|
const db = makeDb({ assignments: [], demands: [] });
|
||||||
|
|
||||||
|
const result = await readProjectScenarioBaseline(db as never, "project_1");
|
||||||
|
|
||||||
|
expect(result.totalCostCents).toBe(0);
|
||||||
|
expect(result.totalHours).toBe(0);
|
||||||
|
expect(result.assignments).toEqual([]);
|
||||||
|
expect(result.demands).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates assignment costCents from lcrCents and effective hours", async () => {
|
||||||
|
// calculateEffectiveBookedHours is mocked to return 0, but calculateAllocation
|
||||||
|
// is not mocked — the assignment has a resourceId, so it will use
|
||||||
|
// calculateEffectiveBookedHours. Re-mock it to return a known value.
|
||||||
|
const { calculateEffectiveBookedHours } = await import("../lib/resource-capacity.js");
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(20);
|
||||||
|
|
||||||
|
const assignment = makeAssignment({ resource: makeResource({ lcrCents: 5000 }) });
|
||||||
|
const db = makeDb({ assignments: [assignment], demands: [] });
|
||||||
|
|
||||||
|
const result = await readProjectScenarioBaseline(db as never, "project_1");
|
||||||
|
|
||||||
|
expect(result.assignments).toHaveLength(1);
|
||||||
|
expect(result.assignments[0].costCents).toBeGreaterThan(0);
|
||||||
|
// 20 hours * 5000 cents/hour = 100_000
|
||||||
|
expect(result.assignments[0].costCents).toBe(100_000);
|
||||||
|
|
||||||
|
// Reset mock to default
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out CANCELLED assignments", async () => {
|
||||||
|
const cancelledAssignment = makeAssignment({ status: "CANCELLED" });
|
||||||
|
// The WHERE clause in the real DB call filters CANCELLED; we simulate
|
||||||
|
// this by having the mock return no assignments (mimicking the db filter).
|
||||||
|
const db = makeDb({ assignments: [], demands: [] });
|
||||||
|
// Verify the findMany was called with the correct filter
|
||||||
|
db.assignment.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await readProjectScenarioBaseline(db as never, "project_1");
|
||||||
|
|
||||||
|
expect(db.assignment.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
status: { not: "CANCELLED" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.assignments).toHaveLength(0);
|
||||||
|
|
||||||
|
// Also test that if somehow a cancelled assignment slips through, the function
|
||||||
|
// still processes it (the filter is at DB level). Validate the WHERE contract.
|
||||||
|
void cancelledAssignment; // used above for context
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps demand requirements with headcount and roleName", async () => {
|
||||||
|
const demand = makeDemand({
|
||||||
|
headcount: 3,
|
||||||
|
roleEntity: { id: "role_2", name: "QA Engineer", color: "#00ff00" },
|
||||||
|
});
|
||||||
|
const db = makeDb({ assignments: [], demands: [demand] });
|
||||||
|
|
||||||
|
const result = await readProjectScenarioBaseline(db as never, "project_1");
|
||||||
|
|
||||||
|
expect(result.demands).toHaveLength(1);
|
||||||
|
expect(result.demands[0].roleName).toBe("QA Engineer");
|
||||||
|
expect(result.demands[0].headcount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("totalCostCents is the sum of all assignment costCents", async () => {
|
||||||
|
const { calculateEffectiveBookedHours } = await import("../lib/resource-capacity.js");
|
||||||
|
// Return 10 hours for each call
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(10);
|
||||||
|
|
||||||
|
const assignment1 = makeAssignment({
|
||||||
|
id: "assignment_1",
|
||||||
|
resource: makeResource({ id: "resource_1", lcrCents: 3000 }),
|
||||||
|
resourceId: "resource_1",
|
||||||
|
});
|
||||||
|
const assignment2 = makeAssignment({
|
||||||
|
id: "assignment_2",
|
||||||
|
resource: makeResource({ id: "resource_2", lcrCents: 4000 }),
|
||||||
|
resourceId: "resource_2",
|
||||||
|
roleEntity: { id: "role_1", name: "Developer", color: "#0000ff" },
|
||||||
|
});
|
||||||
|
const db = makeDb({ assignments: [assignment1, assignment2], demands: [] });
|
||||||
|
|
||||||
|
const result = await readProjectScenarioBaseline(db as never, "project_1");
|
||||||
|
|
||||||
|
// 10 hours * 3000 = 30_000 + 10 hours * 4000 = 40_000 → total = 70_000
|
||||||
|
expect(result.assignments).toHaveLength(2);
|
||||||
|
const sumOfIndividual = result.assignments.reduce((sum, a) => sum + a.costCents, 0);
|
||||||
|
expect(result.totalCostCents).toBe(sumOfIndividual);
|
||||||
|
expect(result.totalCostCents).toBe(70_000);
|
||||||
|
|
||||||
|
// Reset mock
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { calculateAllocation } = vi.hoisted(() => ({
|
||||||
|
calculateAllocation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { calculateEffectiveBookedHours } = vi.hoisted(() => ({
|
||||||
|
calculateEffectiveBookedHours: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capakraken/engine/allocation", () => ({
|
||||||
|
calculateAllocation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/resource-capacity.js", () => ({
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
calculateEffectiveAvailableHours: vi.fn(),
|
||||||
|
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectScenarioSkillSet,
|
||||||
|
DEFAULT_AVAILABILITY,
|
||||||
|
getScenarioAvailability,
|
||||||
|
roundToTenths,
|
||||||
|
calculateScenarioEntryHours,
|
||||||
|
} from "../router/scenario-shared.js";
|
||||||
|
|
||||||
|
describe("roundToTenths", () => {
|
||||||
|
it("rounds 0.15 up to 0.2", () => {
|
||||||
|
expect(roundToTenths(0.15)).toBe(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds 0.34 down to 0.3", () => {
|
||||||
|
expect(roundToTenths(0.34)).toBe(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an integer unchanged", () => {
|
||||||
|
expect(roundToTenths(5)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getScenarioAvailability", () => {
|
||||||
|
it("returns DEFAULT_AVAILABILITY when input is null", () => {
|
||||||
|
expect(getScenarioAvailability(null)).toEqual(DEFAULT_AVAILABILITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns DEFAULT_AVAILABILITY when input is undefined", () => {
|
||||||
|
expect(getScenarioAvailability(undefined)).toEqual(DEFAULT_AVAILABILITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the value as-is when a valid availability object is passed", () => {
|
||||||
|
const custom = {
|
||||||
|
monday: 6,
|
||||||
|
tuesday: 6,
|
||||||
|
wednesday: 6,
|
||||||
|
thursday: 6,
|
||||||
|
friday: 6,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
};
|
||||||
|
expect(getScenarioAvailability(custom)).toBe(custom);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectScenarioSkillSet", () => {
|
||||||
|
it("returns an empty Set for null input", () => {
|
||||||
|
expect(collectScenarioSkillSet(null)).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty Set for an empty array", () => {
|
||||||
|
expect(collectScenarioSkillSet([])).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes skill names to lowercase", () => {
|
||||||
|
const result = collectScenarioSkillSet([{ skill: "Houdini" }]);
|
||||||
|
expect(result).toEqual(new Set(["houdini"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates case-insensitive skill variants (e.g. 'Houdini' and 'houdini')", () => {
|
||||||
|
const result = collectScenarioSkillSet([
|
||||||
|
{ skill: "Houdini" },
|
||||||
|
{ skill: "houdini" },
|
||||||
|
{ skill: "HOUDINI" },
|
||||||
|
]);
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result).toContain("houdini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out blank-string or whitespace-only skill values", () => {
|
||||||
|
const result = collectScenarioSkillSet([
|
||||||
|
{ skill: "" },
|
||||||
|
{ skill: " " },
|
||||||
|
{ skill: "Maya" },
|
||||||
|
]);
|
||||||
|
expect(result).toEqual(new Set(["maya"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateScenarioEntryHours", () => {
|
||||||
|
const baseEntry = {
|
||||||
|
lcrCents: 500,
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
availability: DEFAULT_AVAILABILITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
periodStart: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
periodEnd: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
contexts: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("delegates to calculateAllocation when resourceId is null", () => {
|
||||||
|
calculateAllocation.mockReturnValue({ totalHours: 160 });
|
||||||
|
|
||||||
|
const result = calculateScenarioEntryHours(
|
||||||
|
{ ...baseEntry, resourceId: null },
|
||||||
|
baseOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calculateAllocation).toHaveBeenCalledWith({
|
||||||
|
lcrCents: baseEntry.lcrCents,
|
||||||
|
hoursPerDay: baseEntry.hoursPerDay,
|
||||||
|
startDate: baseEntry.startDate,
|
||||||
|
endDate: baseEntry.endDate,
|
||||||
|
availability: baseEntry.availability,
|
||||||
|
});
|
||||||
|
expect(result).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates to calculateEffectiveBookedHours when resourceId is set", () => {
|
||||||
|
calculateEffectiveBookedHours.mockReturnValue(140);
|
||||||
|
const contexts = new Map([["resource_1", { someContext: true }]]);
|
||||||
|
|
||||||
|
const result = calculateScenarioEntryHours(
|
||||||
|
{ ...baseEntry, resourceId: "resource_1" },
|
||||||
|
{ ...baseOptions, contexts },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calculateEffectiveBookedHours).toHaveBeenCalledWith({
|
||||||
|
availability: baseEntry.availability,
|
||||||
|
startDate: baseEntry.startDate,
|
||||||
|
endDate: baseEntry.endDate,
|
||||||
|
hoursPerDay: baseEntry.hoursPerDay,
|
||||||
|
periodStart: baseOptions.periodStart,
|
||||||
|
periodEnd: baseOptions.periodEnd,
|
||||||
|
context: { someContext: true },
|
||||||
|
});
|
||||||
|
expect(result).toBe(140);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { simulateProjectScenario } from "../router/scenario-simulation.js";
|
||||||
|
|
||||||
|
vi.mock("../lib/resource-capacity.js", () => ({
|
||||||
|
calculateEffectiveAvailableHours: vi.fn().mockReturnValue(160),
|
||||||
|
calculateEffectiveBookedHours: vi.fn().mockReturnValue(0),
|
||||||
|
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DEFAULT_AVAILABILITY = {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Test Project",
|
||||||
|
budgetCents: null,
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeResource(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Alice Tester",
|
||||||
|
eid: "EMP-001",
|
||||||
|
lcrCents: 5000,
|
||||||
|
availability: DEFAULT_AVAILABILITY,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
skills: [],
|
||||||
|
countryId: "DE",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAssignment(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "assignment_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
status: "ACTIVE",
|
||||||
|
resource: makeResource(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUtilizationAssignment(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "assignment_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChange(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a db mock where assignment.findMany is called twice:
|
||||||
|
* 1. for currentAssignments (project assignments)
|
||||||
|
* 2. for allAssignmentsForResources (utilization context)
|
||||||
|
*/
|
||||||
|
function makeDb(overrides: {
|
||||||
|
project?: unknown;
|
||||||
|
currentAssignments?: unknown[];
|
||||||
|
utilizationAssignments?: unknown[];
|
||||||
|
resources?: unknown[];
|
||||||
|
} = {}) {
|
||||||
|
const projectValue = "project" in overrides ? overrides.project : baseProject;
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(projectValue),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(overrides.currentAssignments ?? [])
|
||||||
|
.mockResolvedValueOnce(overrides.utilizationAssignments ?? []),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(overrides.resources ?? [makeResource()]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("simulateProjectScenario", () => {
|
||||||
|
it("throws NOT_FOUND when project does not exist", async () => {
|
||||||
|
const db = makeDb({ project: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_missing",
|
||||||
|
changes: [makeChange()],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Project not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns delta.headcount of 0 when existing assignment is carried through unchanged", async () => {
|
||||||
|
const assignment = makeAssignment();
|
||||||
|
// Pass the existing assignment as a change with its assignmentId (modify path, same data)
|
||||||
|
const db = makeDb({
|
||||||
|
currentAssignments: [assignment],
|
||||||
|
utilizationAssignments: [makeUtilizationAssignment()],
|
||||||
|
resources: [makeResource()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [
|
||||||
|
makeChange({ assignmentId: "assignment_1", resourceId: "resource_1" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// baseline has 1 assignment; scenario also has 1 entry (the modified one)
|
||||||
|
expect(result.delta.headcount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove change decreases headcount delta", async () => {
|
||||||
|
const assignment = makeAssignment();
|
||||||
|
const db = makeDb({
|
||||||
|
currentAssignments: [assignment],
|
||||||
|
utilizationAssignments: [],
|
||||||
|
resources: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [
|
||||||
|
makeChange({ assignmentId: "assignment_1", remove: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// baseline: 1 assignment, scenario: 0 entries → delta = -1
|
||||||
|
expect(result.delta.headcount).toBe(-1);
|
||||||
|
expect(result.baseline.headcount).toBe(1);
|
||||||
|
expect(result.scenario.headcount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("new resource change increases headcount delta", async () => {
|
||||||
|
const assignment = makeAssignment();
|
||||||
|
const newResource = makeResource({ id: "resource_2", displayName: "Bob New" });
|
||||||
|
const db = makeDb({
|
||||||
|
currentAssignments: [assignment],
|
||||||
|
utilizationAssignments: [
|
||||||
|
makeUtilizationAssignment(),
|
||||||
|
makeUtilizationAssignment({ id: "assignment_util_2", resourceId: "resource_2" }),
|
||||||
|
],
|
||||||
|
resources: [makeResource(), newResource],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [
|
||||||
|
// new assignment (no assignmentId) for resource_2
|
||||||
|
makeChange({ resourceId: "resource_2" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// baseline: 1, scenario: 1 existing (carried through) + 1 new = 2 → delta = +1
|
||||||
|
expect(result.delta.headcount).toBe(1);
|
||||||
|
expect(result.baseline.headcount).toBe(1);
|
||||||
|
expect(result.scenario.headcount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits budget warning when scenario exceeds project budget", async () => {
|
||||||
|
const { calculateEffectiveBookedHours } = await import("../lib/resource-capacity.js");
|
||||||
|
// Return a large number of hours so cost = hours * lcrCents > budgetCents
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(1000);
|
||||||
|
|
||||||
|
const projectWithTightBudget = {
|
||||||
|
...baseProject,
|
||||||
|
budgetCents: 100, // tiny budget: 100 cents
|
||||||
|
};
|
||||||
|
const expensiveResource = makeResource({ lcrCents: 50000 });
|
||||||
|
const db = makeDb({
|
||||||
|
project: projectWithTightBudget,
|
||||||
|
currentAssignments: [],
|
||||||
|
utilizationAssignments: [makeUtilizationAssignment({ resourceId: "resource_1" })],
|
||||||
|
resources: [expensiveResource],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [makeChange({ resourceId: "resource_1" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const budgetWarning = result.warnings.find((w) => /exceeds budget/i.test(w));
|
||||||
|
expect(budgetWarning).toBeDefined();
|
||||||
|
|
||||||
|
// Reset mock
|
||||||
|
vi.mocked(calculateEffectiveBookedHours).mockReturnValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports skill coverage when scenario adds new skills", async () => {
|
||||||
|
// Baseline has no assignments (no skills). Scenario adds one resource with a skill.
|
||||||
|
const resourceWithSkill = makeResource({
|
||||||
|
id: "resource_skill",
|
||||||
|
skills: [{ skill: "Blender" }],
|
||||||
|
});
|
||||||
|
const db = makeDb({
|
||||||
|
currentAssignments: [],
|
||||||
|
utilizationAssignments: [makeUtilizationAssignment({ resourceId: "resource_skill" })],
|
||||||
|
resources: [resourceWithSkill],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await simulateProjectScenario(db as never, {
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [makeChange({ resourceId: "resource_skill" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
// baseline skillCount = 0, scenario skillCount = 1
|
||||||
|
// skillCoveragePct: baselineSkillCount === 0 && scenarioSkillCount > 0 → 100
|
||||||
|
expect(result.scenario.skillCount).toBe(1);
|
||||||
|
expect(result.delta.skillCoveragePct).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user