chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CreateRateCardSchema,
|
||||
CreateRateCardLineSchema,
|
||||
UpdateRateCardSchema,
|
||||
} from "../schemas/rate-card.schema.js";
|
||||
import {
|
||||
CreateAllocationBaseSchema,
|
||||
CreateDemandRequirementBaseSchema,
|
||||
CreateAssignmentBaseSchema,
|
||||
CreateAllocationSchema,
|
||||
CreateDemandRequirementSchema,
|
||||
CreateAssignmentSchema,
|
||||
FillDemandRequirementSchema,
|
||||
ShiftProjectSchema,
|
||||
UpdateAllocationHoursSchema,
|
||||
} from "../schemas/allocation.schema.js";
|
||||
import {
|
||||
CreateEstimateSchema,
|
||||
EstimateDemandLineSchema,
|
||||
ScopeItemSchema,
|
||||
EstimateAssumptionSchema,
|
||||
ResourceCostSnapshotSchema,
|
||||
EstimateMetricSchema,
|
||||
EstimateExportSummarySchema,
|
||||
} from "../schemas/estimate.schema.js";
|
||||
import { AllocationStatus, EstimateStatus, EstimateVersionStatus, EstimateExportFormat } from "../types/enums.js";
|
||||
|
||||
// ─── Rate Card Schemas ──────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateRateCardSchema", () => {
|
||||
it("accepts valid input with defaults", () => {
|
||||
const result = CreateRateCardSchema.parse({ name: "Standard Rates" });
|
||||
expect(result.name).toBe("Standard Rates");
|
||||
expect(result.currency).toBe("EUR");
|
||||
expect(result.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts full input with lines and clientId", () => {
|
||||
const result = CreateRateCardSchema.parse({
|
||||
name: "Client Rate Card",
|
||||
currency: "USD",
|
||||
clientId: "client-1",
|
||||
effectiveFrom: "2026-01-01",
|
||||
effectiveTo: "2026-12-31",
|
||||
source: "Import",
|
||||
lines: [{ costRateCents: 5000 }],
|
||||
});
|
||||
expect(result.clientId).toBe("client-1");
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.effectiveFrom).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
expect(() => CreateRateCardSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid currency length", () => {
|
||||
expect(() =>
|
||||
CreateRateCardSchema.parse({ name: "Test", currency: "EURO" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateRateCardLineSchema", () => {
|
||||
it("accepts minimal input", () => {
|
||||
const result = CreateRateCardLineSchema.parse({ costRateCents: 8000 });
|
||||
expect(result.costRateCents).toBe(8000);
|
||||
expect(result.attributes).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts full input with all optional fields", () => {
|
||||
const result = CreateRateCardLineSchema.parse({
|
||||
roleId: "role-1",
|
||||
chapter: "3D",
|
||||
location: "Munich",
|
||||
seniority: "Senior",
|
||||
workType: "Production",
|
||||
serviceGroup: "CGI",
|
||||
costRateCents: 8000,
|
||||
billRateCents: 12000,
|
||||
machineRateCents: 500,
|
||||
attributes: { tier: "A" },
|
||||
});
|
||||
expect(result.chapter).toBe("3D");
|
||||
expect(result.billRateCents).toBe(12000);
|
||||
});
|
||||
|
||||
it("rejects negative cost rate", () => {
|
||||
expect(() => CreateRateCardLineSchema.parse({ costRateCents: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer cost rate", () => {
|
||||
expect(() => CreateRateCardLineSchema.parse({ costRateCents: 100.5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateRateCardSchema", () => {
|
||||
it("accepts partial updates", () => {
|
||||
const result = UpdateRateCardSchema.parse({ name: "Updated" });
|
||||
expect(result.name).toBe("Updated");
|
||||
expect(result.currency).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts nullable clientId", () => {
|
||||
const result = UpdateRateCardSchema.parse({ clientId: null });
|
||||
expect(result.clientId).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts nullable effectiveFrom", () => {
|
||||
const result = UpdateRateCardSchema.parse({ effectiveFrom: null });
|
||||
expect(result.effectiveFrom).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Allocation Schemas ─────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateDemandRequirementSchema", () => {
|
||||
const validDemand = {
|
||||
projectId: "proj-1",
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-03-31",
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
};
|
||||
|
||||
it("accepts valid demand requirement", () => {
|
||||
const result = CreateDemandRequirementSchema.parse(validDemand);
|
||||
expect(result.projectId).toBe("proj-1");
|
||||
expect(result.headcount).toBe(1);
|
||||
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects end date before start date", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementSchema.parse({
|
||||
...validDemand,
|
||||
startDate: "2026-03-31",
|
||||
endDate: "2026-03-01",
|
||||
}),
|
||||
).toThrow("End date must be after start date");
|
||||
});
|
||||
|
||||
it("rejects hoursPerDay > 24", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementBaseSchema.parse({ ...validDemand, hoursPerDay: 25 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects percentage > 100", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementBaseSchema.parse({ ...validDemand, percentage: 101 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateAssignmentSchema", () => {
|
||||
const validAssignment = {
|
||||
resourceId: "res-1",
|
||||
projectId: "proj-1",
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-03-31",
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
};
|
||||
|
||||
it("accepts valid assignment", () => {
|
||||
const result = CreateAssignmentSchema.parse(validAssignment);
|
||||
expect(result.resourceId).toBe("res-1");
|
||||
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
||||
});
|
||||
|
||||
it("requires resourceId", () => {
|
||||
const { resourceId: _, ...withoutResource } = validAssignment;
|
||||
expect(() => CreateAssignmentBaseSchema.parse(withoutResource)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects end date before start date", () => {
|
||||
expect(() =>
|
||||
CreateAssignmentSchema.parse({
|
||||
...validAssignment,
|
||||
startDate: "2026-03-31",
|
||||
endDate: "2026-03-01",
|
||||
}),
|
||||
).toThrow("End date must be after start date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FillDemandRequirementSchema", () => {
|
||||
it("accepts valid fill input", () => {
|
||||
const result = FillDemandRequirementSchema.parse({
|
||||
demandRequirementId: "demand-1",
|
||||
resourceId: "res-1",
|
||||
});
|
||||
expect(result.demandRequirementId).toBe("demand-1");
|
||||
expect(result.hoursPerDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects hoursPerDay below 0.5", () => {
|
||||
expect(() =>
|
||||
FillDemandRequirementSchema.parse({
|
||||
demandRequirementId: "d-1",
|
||||
resourceId: "r-1",
|
||||
hoursPerDay: 0.3,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShiftProjectSchema", () => {
|
||||
it("accepts valid shift", () => {
|
||||
const result = ShiftProjectSchema.parse({
|
||||
projectId: "proj-1",
|
||||
newStartDate: "2026-04-01",
|
||||
newEndDate: "2026-06-30",
|
||||
});
|
||||
expect(result.projectId).toBe("proj-1");
|
||||
expect(result.newStartDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects end before start", () => {
|
||||
expect(() =>
|
||||
ShiftProjectSchema.parse({
|
||||
projectId: "proj-1",
|
||||
newStartDate: "2026-06-30",
|
||||
newEndDate: "2026-04-01",
|
||||
}),
|
||||
).toThrow("New end date must be after new start date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Estimate Schemas ───────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateEstimateSchema", () => {
|
||||
it("accepts minimal input", () => {
|
||||
const result = CreateEstimateSchema.parse({ name: "Q2 Estimate" });
|
||||
expect(result.name).toBe("Q2 Estimate");
|
||||
expect(result.baseCurrency).toBe("EUR");
|
||||
expect(result.status).toBe(EstimateStatus.DRAFT);
|
||||
expect(result.assumptions).toEqual([]);
|
||||
expect(result.scopeItems).toEqual([]);
|
||||
expect(result.demandLines).toEqual([]);
|
||||
expect(result.resourceSnapshots).toEqual([]);
|
||||
expect(result.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
expect(() => CreateEstimateSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EstimateDemandLineSchema", () => {
|
||||
it("accepts minimal demand line", () => {
|
||||
const result = EstimateDemandLineSchema.parse({
|
||||
name: "3D Artist",
|
||||
hours: 160,
|
||||
});
|
||||
expect(result.name).toBe("3D Artist");
|
||||
expect(result.hours).toBe(160);
|
||||
expect(result.lineType).toBe("LABOR");
|
||||
expect(result.currency).toBe("EUR");
|
||||
expect(result.costRateCents).toBe(0);
|
||||
expect(result.monthlySpread).toEqual({});
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts full demand line with metadata", () => {
|
||||
const result = EstimateDemandLineSchema.parse({
|
||||
name: "Senior 3D Artist",
|
||||
hours: 320,
|
||||
days: 40,
|
||||
fte: 1,
|
||||
roleId: "role-1",
|
||||
resourceId: "res-1",
|
||||
chapter: "3D Visualization",
|
||||
costRateCents: 8000,
|
||||
billRateCents: 12000,
|
||||
costTotalCents: 256_000_0,
|
||||
priceTotalCents: 384_000_0,
|
||||
monthlySpread: { "2026-03": 80, "2026-04": 80 },
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "manual",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.chapter).toBe("3D Visualization");
|
||||
expect(result.monthlySpread["2026-03"]).toBe(80);
|
||||
expect(result.metadata.calculation?.costRateMode).toBe("resource");
|
||||
});
|
||||
|
||||
it("rejects negative hours", () => {
|
||||
expect(() =>
|
||||
EstimateDemandLineSchema.parse({ name: "Test", hours: -1 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScopeItemSchema", () => {
|
||||
it("accepts valid scope item", () => {
|
||||
const result = ScopeItemSchema.parse({
|
||||
sequenceNo: 1,
|
||||
scopeType: "shot",
|
||||
name: "Hero shot exterior",
|
||||
});
|
||||
expect(result.sequenceNo).toBe(1);
|
||||
expect(result.technicalSpec).toEqual({});
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects negative sequenceNo", () => {
|
||||
expect(() =>
|
||||
ScopeItemSchema.parse({ sequenceNo: -1, scopeType: "shot", name: "Test" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceCostSnapshotSchema", () => {
|
||||
it("accepts valid snapshot", () => {
|
||||
const result = ResourceCostSnapshotSchema.parse({
|
||||
displayName: "John Doe",
|
||||
lcrCents: 6000,
|
||||
ucrCents: 4000,
|
||||
});
|
||||
expect(result.displayName).toBe("John Doe");
|
||||
expect(result.currency).toBe("EUR");
|
||||
});
|
||||
|
||||
it("rejects negative LCR", () => {
|
||||
expect(() =>
|
||||
ResourceCostSnapshotSchema.parse({
|
||||
displayName: "Test",
|
||||
lcrCents: -100,
|
||||
ucrCents: 0,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EstimateExportSummarySchema", () => {
|
||||
it("accepts valid export summary", () => {
|
||||
const result = EstimateExportSummarySchema.parse({
|
||||
estimateId: "est-1",
|
||||
estimateName: "Test Estimate",
|
||||
versionId: "ver-1",
|
||||
versionNumber: 1,
|
||||
versionStatus: EstimateVersionStatus.APPROVED,
|
||||
baseCurrency: "EUR",
|
||||
assumptionCount: 5,
|
||||
scopeItemCount: 10,
|
||||
demandLineCount: 8,
|
||||
resourceSnapshotCount: 3,
|
||||
totalHours: 1200,
|
||||
totalCostCents: 600_000_00,
|
||||
totalPriceCents: 900_000_00,
|
||||
marginCents: 300_000_00,
|
||||
marginPercent: 33.33,
|
||||
});
|
||||
expect(result.marginPercent).toBeCloseTo(33.33);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user