From e3c585a403a426228e7dbf58670d929ba028031c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 2 Apr 2026 21:30:46 +0200 Subject: [PATCH] test(scenario): add unit regression coverage for all four scenario modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/src/__tests__/scenario-apply.test.ts | 154 +++++++++++ .../src/__tests__/scenario-baseline.test.ts | 214 +++++++++++++++ .../api/src/__tests__/scenario-shared.test.ts | 153 +++++++++++ .../src/__tests__/scenario-simulation.test.ts | 244 ++++++++++++++++++ 4 files changed, 765 insertions(+) create mode 100644 packages/api/src/__tests__/scenario-apply.test.ts create mode 100644 packages/api/src/__tests__/scenario-baseline.test.ts create mode 100644 packages/api/src/__tests__/scenario-shared.test.ts create mode 100644 packages/api/src/__tests__/scenario-simulation.test.ts diff --git a/packages/api/src/__tests__/scenario-apply.test.ts b/packages/api/src/__tests__/scenario-apply.test.ts new file mode 100644 index 0000000..41fceb2 --- /dev/null +++ b/packages/api/src/__tests__/scenario-apply.test.ts @@ -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; + assignmentUpdate?: ReturnType; + assignmentCreate?: ReturnType; + resourceFindUnique?: ReturnType; +}) { + 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; + let assignmentCreate: ReturnType; + let resourceFindUnique: ReturnType; + + 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); + }); +}); diff --git a/packages/api/src/__tests__/scenario-baseline.test.ts b/packages/api/src/__tests__/scenario-baseline.test.ts new file mode 100644 index 0000000..811add6 --- /dev/null +++ b/packages/api/src/__tests__/scenario-baseline.test.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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); + }); +}); diff --git a/packages/api/src/__tests__/scenario-shared.test.ts b/packages/api/src/__tests__/scenario-shared.test.ts new file mode 100644 index 0000000..64ebce2 --- /dev/null +++ b/packages/api/src/__tests__/scenario-shared.test.ts @@ -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); + }); +}); diff --git a/packages/api/src/__tests__/scenario-simulation.test.ts b/packages/api/src/__tests__/scenario-simulation.test.ts new file mode 100644 index 0000000..9dbcffc --- /dev/null +++ b/packages/api/src/__tests__/scenario-simulation.test.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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); + }); +});