Files
CapaKraken/packages/engine/src/__tests__/experience-multiplier.test.ts
T

331 lines
11 KiB
TypeScript

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);
});
});