import { describe, it, expect } from "vitest"; import { previewAllocation, type FixedPlan, type VariableCategory } from "../src/utils/allocatorPreview"; const cats = (defs: Array & { name: string }>): VariableCategory[] => defs.map((d, i) => ({ id: String(i + 1), name: d.name, percent: d.percent ?? 0, isSavings: d.isSavings ?? false, priority: d.priority ?? 100, })); const plans = (defs: Array & { name: string; totalCents: number }>): FixedPlan[] => defs.map((d, i) => ({ id: String(i + 1), name: d.name, totalCents: d.totalCents, fundedCents: d.fundedCents ?? 0, priority: d.priority ?? 100, dueOn: d.dueOn ?? new Date().toISOString(), cycleStart: d.cycleStart ?? new Date().toISOString(), })); describe("previewAllocation — basics", () => { it("single bucket 100% gets all remaining after fixed", () => { const fp = plans([]); const vc = cats([{ name: "Only", percent: 100 }]); const r = previewAllocation(1_000, fp, vc); expect(r.fixed.length).toBe(0); expect(r.variable).toEqual([{ id: 1, name: "Only", amountCents: 1_000 }]); expect(r.unallocatedCents).toBe(0); }); it("zero allocations when amount is 0", () => { const r = previewAllocation(0, plans([]), cats([{ name: "A", percent: 100 }])); expect(r.fixed.length).toBe(0); expect(r.variable.length).toBe(0); expect(r.unallocatedCents).toBe(0); }); it("no variable split if no categories", () => { const r = previewAllocation(500, plans([]), []); expect(r.fixed.length).toBe(0); expect(r.variable.length).toBe(0); expect(r.unallocatedCents).toBe(500); }); }); describe("previewAllocation — fixed first", () => { it("funds plans in priority then due order up to need", () => { const fp = plans([ { name: "B", totalCents: 8000, fundedCents: 7000, priority: 2, dueOn: "2025-12-31T00:00:00Z" }, // need 1000 { name: "A", totalCents: 5000, fundedCents: 0, priority: 1, dueOn: "2025-12-01T00:00:00Z" }, // need 5000 (but priority=1 => first) ]); const vc = cats([{ name: "Var", percent: 100 }]); const r = previewAllocation(4000, fp, vc); // Fixed plan A (priority 1) gets as much as possible: 4000 (need 5000) expect(r.fixed).toEqual([{ id: 2, name: "A", amountCents: 4000 }]); expect(r.variable.length).toBe(0); expect(r.unallocatedCents).toBe(0); }); it("leftover goes to variables after satisfying plan needs", () => { const fp = plans([{ name: "Rent", totalCents: 10000, fundedCents: 9000, priority: 1 }]); // need 1000 const vc = cats([{ name: "Groceries", percent: 100 }]); const r = previewAllocation(2500, fp, vc); expect(r.fixed).toEqual([{ id: 1, name: "Rent", amountCents: 1000 }]); expect(r.variable).toEqual([{ id: 1, name: "Groceries", amountCents: 1500 }]); expect(r.unallocatedCents).toBe(0); }); }); describe("previewAllocation — largest remainder with savings-first tiebreak", () => { it("splits by integer floor and then leftover by remainder", () => { // 3 cats: 50%, 30%, 20%; amount 101 -> floors: 50, 30, 20 => sumBase=100, 1 leftover to largest remainder (all remainders 0, so go to savings-first if any) const vc = cats([ { name: "A", percent: 50, isSavings: false, priority: 10 }, { name: "B", percent: 30, isSavings: true, priority: 10 }, { name: "C", percent: 20, isSavings: false, priority: 10 }, ]); const r = previewAllocation(101, plans([]), vc); // Base: A50, B30, C20 = 100; leftover=1 -> goes to B (savings-first) expect(r.variable).toEqual([ { id: 1, name: "A", amountCents: 50 }, { id: 2, name: "B", amountCents: 31 }, { id: 3, name: "C", amountCents: 20 }, ]); }); it("ties on remainder resolved by savings-first, then priority asc, then name asc", () => { // Force equal remainders with 3 categories @ 33.333…% const vc = cats([ { name: "Zeta", percent: 33, isSavings: false, priority: 2 }, { name: "Alpha", percent: 33, isSavings: false, priority: 1 }, { name: "Saver", percent: 34, isSavings: true, priority: 5 }, ]); // Amount 4: exacts ~ 1.32, 1.32, 1.36 => floors 1,1,1 sumBase 3 leftover 1 -> goes to Saver (savings-first) const r = previewAllocation(4, plans([]), vc); expect(r.variable).toEqual([ { id: 1, name: "Zeta", amountCents: 1 }, { id: 2, name: "Alpha", amountCents: 1 }, { id: 3, name: "Saver", amountCents: 2 }, ]); }); }); describe("previewAllocation — normalization safety", () => { it("normalizes percents when sum != 100 to avoid crashing UI", () => { const vc = cats([ { name: "A", percent: 40 }, { name: "B", percent: 40 }, { name: "C", percent: 40 }, // sum = 120 -> normalize ]); const r = previewAllocation(120, plans([]), vc); // Expect all cents allocated (no unallocated) and proportions roughly 1/3 each. const total = r.variable.reduce((s, v) => s + v.amountCents, 0); expect(total).toBe(120); expect(r.unallocatedCents).toBe(0); }); });