chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user