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