Files
certain/backend/tests/test_budget.py
T
GuidoandClaude Opus 5 63056ae76a feat: Ringdiagramm faerbt Legende und Mitte nach Budget
Eingefaerbt war bisher nur die Listen-Darstellung der Ausgaben-Anteile. In der
Kreis-Darstellung blieben die Betraege der Legende grau, und die Summe in der
Mitte sagte gar nichts ueber den Stand.

Jetzt tragen beide die Aussage: gruen im geplanten Rahmen, rot darueber. Faehrt
man ein Segment an, zeigt die Mitte dessen Betrag – dann gilt dort das Budget
dieser Kategorie, sonst das des Ganzen.

Fuer die Mitte liefert der Server jetzt auch das Geplante ueber ALLE
Ausgaben-Einheiten, nicht nur ueber die mit Ausgaben: Der Monatsplan umfasst
auch, wofuer noch nichts ausgegeben wurde.

Ohne Plan bleibt alles, wie es war. Der Donut faerbt nur, wenn ein Segment die
Angabe mitbringt – die Budget-Karte, die dieselbe Komponente benutzt, bleibt
also unberuehrt.

Der Test hat dabei gleich einen Fehler gefunden: ``_units`` ist ein Generator,
und ich lief zweimal darueber – beim zweiten Mal war er leer und das Geplante
immer 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:13:49 +02:00

218 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date
def test_period_bounds_and_labels():
from app.services import budget
# Gehalt am 25.: September-Budget läuft 25.08.–24.09.
assert budget.period_bounds("2026-09", 25) == (date(2026, 8, 25), date(2026, 9, 24))
# Starttag 1: Kalendermonat.
assert budget.period_bounds("2026-09", 1) == (date(2026, 9, 1), date(2026, 9, 30))
# Label-Zuordnung
assert budget.period_of(date(2026, 8, 27), 25) == "2026-09"
assert budget.period_of(date(2026, 9, 10), 25) == "2026-09"
assert budget.period_of(date(2026, 9, 26), 25) == "2026-10"
assert budget.period_of(date(2026, 9, 15), 1) == "2026-09"
assert budget.shift_label("2026-09", 1) == "2026-10"
assert budget.shift_label("2026-01", -1) == "2025-12"
def _lebensmittel(admin):
groups = admin.get("/api/categories").json()
for g in groups:
for c in g["categories"]:
if c["name"] == "Lebensmittel":
return c["id"]
raise AssertionError("Lebensmittel-Kategorie fehlt")
def _gehalt(admin):
groups = admin.get("/api/categories").json()
for g in groups:
for c in g["categories"]:
if c["name"] == "Gehalt":
return c["id"]
raise AssertionError("Gehalt-Kategorie fehlt")
def test_budget_soll_ist_and_override(admin):
# Starttag 1 -> aktueller Zeitraum = Kalendermonat, heutige Buchungen zählen.
admin.put("/api/settings/budget_start_day", params={"value": "1"})
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
acc = admin.post("/api/accounts", json={"name": "Budgetkonto", "currency": "EUR"}).json()
leb = _lebensmittel(admin)
# Standard-Budget für Lebensmittel: 500,00 EUR.
admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000})
# Ausgabe 300,00 EUR heute.
admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb})
v = admin.get("/api/budget/current").json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["planned_cents"] == 50000
assert row["actual_cents"] == 30000
assert row["over"] is False
assert v["customized"] is False
# Noch 300 EUR -> insgesamt 600 > 500 -> über Budget.
admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb})
v = admin.get(f"/api/budget/{label}").json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["actual_cents"] == 60000
assert row["over"] is True
# Monats-Override auf 700 EUR -> nicht mehr über Budget, Monat individualisiert.
v = admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000}).json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["planned_cents"] == 70000
assert row["over"] is False
assert v["customized"] is True
# Vorlage bleibt unverändert.
tpl = admin.get("/api/budget/template").json()
tpl_leb = next(l for l in tpl["lines"] if l["category_id"] == leb)
assert tpl_leb["planned_cents"] == 50000
# Zurücksetzen -> wieder Vorlagenwert, nicht mehr individualisiert.
v = admin.delete(f"/api/budget/{label}").json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["planned_cents"] == 50000
assert v["customized"] is False
def test_budget_copy_month_and_template(admin):
admin.put("/api/settings/budget_start_day", params={"value": "1"})
today = date.today()
label = f"{today.year:04d}-{today.month:02d}"
leb = _lebensmittel(admin)
admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000})
# Monat individuell auf 700.
admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000})
# Monat -> Vorlage: Vorlage übernimmt den 700er Wert.
r = admin.post(f"/api/budget/template/from-month/{label}")
assert r.status_code == 200, r.text
tpl = r.json()
assert next(l for l in tpl["lines"] if l["category_id"] == leb)["planned_cents"] == 70000
# Vorlage auf 900 ändern, dann Vorlage -> Monat (überschreibt den angepassten Monat).
admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 90000})
v = admin.post(f"/api/budget/{label}/from-template").json()
row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb)
assert row["planned_cents"] == 90000
assert v["customized"] is True
def test_planned_income(admin):
admin.put("/api/settings/budget_start_day", params={"value": "1"})
today = date.today()
acc = admin.post("/api/accounts", json={"name": "Lohnkonto", "currency": "EUR"}).json()
gehalt = _gehalt(admin)
r = admin.post("/api/budget/income", json={
"label": "Gehalt", "category_id": gehalt, "target_account_id": acc["id"], "planned_cents": 300000,
})
assert r.status_code == 201
# Tatsächliche Einnahme 3000 EUR.
admin.post("/api/transactions", json={"type": "income", "date": str(today), "account_id": acc["id"], "amount_cents": 300000, "category_id": gehalt})
v = admin.get("/api/budget/current").json()
line = next(i for i in v["income"] if i["label"] == "Gehalt")
assert line["planned_cents"] == 300000
assert line["received_cents"] == 300000
assert v["totals"]["planned_income"] >= 300000
def test_jeder_tag_liegt_in_genau_seinem_budget_monat():
"""period_of und period_bounds muessen dasselbe sagen.
Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31.
Februar gibt es nicht. Ohne dieselbe Kappung auf beiden Seiten fiel der
28. Februar bei Starttag 31 in einen Monat, der ihn nicht enthaelt: Die
Ausgabe tauchte in keiner Auswertung auf.
"""
from datetime import date, timedelta
from app.services.budget import period_bounds, period_of
fehler = []
for start_day in range(1, 32):
tag = date(2024, 1, 1) # Schaltjahr eingeschlossen
while tag < date(2028, 1, 1):
von, bis = period_bounds(period_of(tag, start_day), start_day)
if not von <= tag <= bis:
fehler.append((start_day, tag.isoformat()))
tag += timedelta(days=1)
assert not fehler, f"Tage ausserhalb ihres Budget-Monats: {fehler[:5]}"
def test_budget_monate_stossen_lueckenlos_aneinander():
"""Kein Tag darf zwischen zwei Monate fallen und keiner in beide."""
from datetime import timedelta
from app.services.budget import period_bounds, shift_label
for start_day in (1, 15, 25, 28, 29, 31):
label = "2026-01"
for _ in range(24):
_, bis = period_bounds(label, start_day)
naechstes = shift_label(label, 1)
von_naechstes, _ = period_bounds(naechstes, start_day)
assert von_naechstes == bis + timedelta(days=1), (
f"Starttag {start_day}: {label} endet {bis}, "
f"{naechstes} beginnt {von_naechstes}")
label = naechstes
def test_ausgabenanteile_nennen_das_geplante(admin):
"""Die Karte soll zeigen, ob ein Anteil noch im Rahmen liegt. Ohne Plan
wird nichts behauptet – 'ueber Budget' waere sonst die Aussage ueber jede
Kategorie, fuer die nie eine Vorgabe gemacht wurde."""
import datetime
leb = _lebensmittel(admin)
name = next(c["name"] for g in admin.get("/api/categories").json()
for c in g["categories"] if c["id"] == leb)
konto = admin.post("/api/accounts", json={
"name": "Anteile", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 0}).json()
admin.post("/api/transactions", json={
"type": "expense", "date": datetime.date.today().isoformat(),
"account_id": konto["id"], "amount_cents": 60000, "category_id": leb})
# Ueber den MONATS-Wert, nicht die Vorlage: Ein vorhandener Monats-Wert
# aus einem anderen Test uebersteuert die Vorlage sonst.
label = admin.get("/api/budget/current").json()["label"]
def plane(cents):
r = admin.put(f"/api/budget/{label}/category/{leb}",
params={"planned_cents": cents})
assert r.status_code == 200, r.text
def zeile():
rows = admin.get("/api/dashboard/expense-share").json()["rows"]
return next(r for r in rows if r["name"] == name)
ist = zeile()["amount"]
plane(0)
assert zeile()["planned"] == 0
assert zeile()["over"] is False, "ohne Plan darf nichts als ueberschritten gelten"
plane(ist + 10000)
assert zeile()["over"] is False, "im Rahmen"
plane(max(1, ist - 10000))
assert zeile()["over"] is True, "ueberschritten"
# Und die Mitte des Ringdiagramms bekommt dieselbe Grundlage fuer das
# Ganze – sonst faerbte sich die Legende, die Summe aber nicht.
ganz = admin.get("/api/dashboard/expense-share").json()
assert ganz["total_planned"] > 0
assert ganz["total_over"] == (ganz["total"] > ganz["total_planned"])