import { describe, expect, it } from "vitest"; import { budgetPeriod, computeBudget, daysBetween, forecast, periodOfDate, roundUpLimit, shiftMonth, statusOf, type CategoryAmounts, } from "./budget"; describe("bütçe dönemi", () => { it("takvim ayı ve 15'inde başlayan ay", () => { expect(budgetPeriod("2026-11")).toEqual({ from: "2026-11-01", to: "2026-11-30" }); expect(budgetPeriod("2026-12", 15)).toEqual({ from: "2026-12-15", to: "2027-01-14" }); expect(budgetPeriod("2027-01", 28)).toEqual({ from: "2027-01-28", to: "2027-02-27" }); expect(periodOfDate("2026-11-14", 15)).toBe("2026-10"); expect(periodOfDate("2026-11-15", 15)).toBe("2026-11"); expect(periodOfDate("2027-01-03", 15)).toBe("2026-12"); expect(periodOfDate("2026-11-30")).toBe("2026-11"); expect(shiftMonth("2026-12", 1)).toBe("2027-01"); expect(shiftMonth("2026-01", -1)).toBe("2025-12"); expect(daysBetween("2026-11-01", "2026-11-30")).toBe(30); expect(daysBetween("2026-12-15", "2027-01-14")).toBe(31); }); it("durum eşikleri %80 ve %100", () => { expect(statusOf(79_99, 100_00)).toBe("ok"); expect(statusOf(80_00, 100_00)).toBe("warning"); expect(statusOf(100_00, 100_00)).toBe("over"); expect(statusOf(5, null)).toBe("none"); expect(statusOf(1, 0)).toBe("over"); }); it("ay sonu tahmini: sabit + planlı + değişken tempo", () => { const a: CategoryAmounts = { fixedMinor: 1_000_00, variableMinor: 3_000_00, plannedMinor: 500_00, }; // Geçmiş yok: 10/30 gün geçti, değişken 3.000 → 9.000 expect(forecast(a, 10, 30)).toBe(10_500_00); expect(forecast(a, 30, 30)).toBe(4_500_00); // Henüz başlamamış dönem: gerçekleşmiş değişken olduğu gibi, tempo yok. expect(forecast(a, 0, 30)).toBe(4_500_00); expect(roundUpLimit(1_234_56)).toBe(1_300_00); expect(roundUpLimit(1_200_00)).toBe(1_200_00); }); it("taban hız: önceki ayın temposu ay başında ağır basar", () => { // Ayın 2'sinde 6.000'lik tek harcama; önceki ay 30 günde 9.000 (günde 300). const a: CategoryAmounts = { fixedMinor: 0, variableMinor: 6_000_00, plannedMinor: 0, baselineMinor: 9_000_00, }; // Yalnızca bu ay: 6.000 × 30/2 = 90.000. expect(forecast(a, 2, 30)).toBe(90_000_00); // Karışım: hız = 2/30 × 3.000 + 28/30 × 300 = 480 → 6.000 + 28 × 480 expect(forecast(a, 2, 30, 30)).toBe(6_000_00 + 28 * 480_00); // Gelecek ay: yalnızca taban; geçmiş ay: gerçekleşen. expect(forecast({ ...a, variableMinor: 0 }, 0, 30, 30)).toBe(9_000_00); expect(forecast(a, 30, 30, 30)).toBe(6_000_00); }); }); describe("bütçe toplama", () => { const categories = [ { id: "market", parentId: null, name: "Market", order: 1 }, { id: "faturalar", parentId: null, name: "Faturalar", order: 2 }, { id: "elektrik", parentId: "faturalar", name: "Elektrik", order: 1 }, { id: "su", parentId: "faturalar", name: "Su", order: 2 }, { id: "eglence", parentId: null, name: "Eğlence", order: 3 }, { id: "sinema", parentId: "eglence", name: "Sinema", order: 1 }, { id: "giyim", parentId: null, name: "Giyim", order: 4 }, ]; const amt = (variable: number, fixed = 0, planned = 0): CategoryAmounts => ({ fixedMinor: fixed, variableMinor: variable, plannedMinor: planned, }); it("üst limit alt kategorileri kapsar; alt limit iki kez sayılmaz; bütçesiz harcama", () => { const r = computeBudget({ categories, limits: new Map([ ["market", 10_000_00], ["faturalar", 3_000_00], ["elektrik", 1_500_00], ["sinema", 500_00], ]), amounts: new Map([ ["market", amt(8_500_00)], ["elektrik", amt(0, 1_600_00)], ["su", amt(0, 400_00, 300_00)], ["faturalar", amt(100_00)], ["sinema", amt(200_00)], ["eglence", amt(300_00)], ["giyim", amt(1_000_00)], ]), elapsedDays: 30, totalDays: 30, }); // Toplam limit: market + faturalar + sinema (elektrik faturaların içinde). expect(r.totals.limitMinor).toBe(13_500_00); expect(r.totals.actualMinor).toBe(12_100_00); // Bütçesiz: eğlencenin kendi harcaması + giyim. expect(r.totals.unbudgetedMinor).toBe(1_300_00); const line = (id: string) => r.lines.find((l) => l.categoryId === id)!; expect(line("faturalar")).toMatchObject({ actualMinor: 2_100_00, plannedMinor: 300_00, status: "ok", ratioBp: 7000, }); expect(line("elektrik")).toMatchObject({ status: "over", remainingMinor: -100_00 }); expect(line("market").status).toBe("warning"); expect(line("eglence")).toMatchObject({ limitMinor: null, actualMinor: 500_00, status: "none", }); // Sıra: ağaç sırası, derinlik. expect(r.lines.map((l) => `${l.depth}:${l.categoryId}`)).toEqual([ "0:market", "0:faturalar", "1:elektrik", "1:su", "0:eglence", "1:sinema", "0:giyim", ]); }); it("tahmin limiti aşıyor uyarısı; limit yoksa durum none", () => { const r = computeBudget({ categories, limits: new Map([["market", 10_000_00]]), amounts: new Map([["market", amt(4_000_00)]]), elapsedDays: 10, totalDays: 30, }); expect(r.totals).toMatchObject({ status: "ok", forecastMinor: 12_000_00, forecastOver: true }); const empty = computeBudget({ categories, limits: new Map(), amounts: new Map(), elapsedDays: 1, totalDays: 30, }); expect(empty.totals.status).toBe("none"); expect(empty.lines).toHaveLength(0); }); });