fix: Szenario-Vergleich zeigt vor heute Ist-Saldo, veraltete Szenarien werden neu gerechnet (A11-T2)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:10:51 +02:00
co-authored by Claude Opus 5.5
parent 32d78fdbe1
commit 979e1c0edc
7 changed files with 261 additions and 5 deletions
+34
View File
@@ -531,3 +531,37 @@ def test_szenario_loeschen_roundtrip_gui(client, db):
db.commit()
assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204
assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text
def test_szenarien_seite_rechnet_veraltete_projektion_neu(client, db):
from sqlalchemy import func, select
from app.models.tables import ProjectionPoint
from app.services.projection_service import run_projection
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Veraltet", description="", include_recurring=False,
include_planned=False)
db.add(sc)
db.commit()
run_projection(db, sc, 10, date.today() - timedelta(days=3))
assert client.get("/szenarien").status_code == 200
first = db.execute(select(func.min(ProjectionPoint.day))
.where(ProjectionPoint.scenario_id == sc.id)).scalar()
assert first == date.today() + timedelta(days=1)
def test_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
def kaputt(session, today):
raise RuntimeError("absichtlich kaputt")
monkeypatch.setattr("app.routers.gui.refresh_stale_projections", kaputt)
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/szenarien"):
assert client.get(path).status_code == 200, path
def test_v_projection_zeigt_ist_serie_und_keine_alten_punkte():
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS
body = VIEWS["v_projection"]
assert f"'{RESERVED_SCENARIO_NAME}'" in body
assert "p.day > CURRENT_DATE" in body
names = list(VIEWS)
assert names.index("v_balance_total") < names.index("v_projection")
+66
View File
@@ -0,0 +1,66 @@
from datetime import date, timedelta
from sqlalchemy import func, select
from app.config import get_settings
from app.models.tables import ProjectionPoint, ProjectionResult, Scenario
from app.services.projection_service import refresh_stale_projections, run_projection
TODAY = date(2026, 9, 28)
def _scenario(db, name):
sc = Scenario(name=name, description="", include_recurring=False,
include_planned=False)
db.add(sc)
db.commit()
return sc
def _first_day(db, sid):
return db.execute(select(func.min(ProjectionPoint.day))
.where(ProjectionPoint.scenario_id == sid)).scalar()
def _count(db, sid):
return db.execute(select(func.count()).select_from(ProjectionPoint)
.where(ProjectionPoint.scenario_id == sid)).scalar()
def test_veraltetes_szenario_wird_ab_heute_neu_gerechnet(db):
sc = _scenario(db, "Alt")
run_projection(db, sc, 10, TODAY - timedelta(days=5))
assert _first_day(db, sc.id) == TODAY - timedelta(days=4)
assert refresh_stale_projections(db, TODAY) == 1
assert _first_day(db, sc.id) == TODAY + timedelta(days=1)
assert _count(db, sc.id) == get_settings().horizon_days
def test_gestern_gerechnet_gilt_als_veraltet(db):
sc = _scenario(db, "Gestern")
run_projection(db, sc, 10, TODAY - timedelta(days=1))
assert refresh_stale_projections(db, TODAY) == 1
def test_heute_gerechnet_bleibt_unveraendert(db):
sc = _scenario(db, "Heute")
run_projection(db, sc, 10, TODAY)
before = db.get(ProjectionResult, sc.id).computed_at
assert refresh_stale_projections(db, TODAY) == 0
assert _count(db, sc.id) == 10
assert db.get(ProjectionResult, sc.id).computed_at == before
def test_nie_gerechnetes_szenario_bleibt_ohne_ergebnis(db):
sc = _scenario(db, "Nie")
assert refresh_stale_projections(db, TODAY) == 0
assert db.get(ProjectionResult, sc.id) is None
assert _count(db, sc.id) == 0
def test_refresh_ist_idempotent(db):
sc = _scenario(db, "Zweimal")
run_projection(db, sc, 10, TODAY - timedelta(days=3))
assert refresh_stale_projections(db, TODAY) == 1
assert refresh_stale_projections(db, TODAY) == 0
assert _count(db, sc.id) == get_settings().horizon_days
+82
View File
@@ -0,0 +1,82 @@
"""Postgres-only-Prüfung der Grafana-Views (Ausbaustufe 11).
Läuft nur mit FB_TEST_PG_URL (Wegwerf-Postgres, DB-Name muss auf '_test'
enden - Schutz gegen versehentliche Ausführung gegen die Produktiv-DB).
Beispiel:
podman run -d --rm --name a11-pg -e POSTGRES_PASSWORD=wegwerf \
-e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10
FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \
.venv/bin/python -m pytest -q tests/test_views_pg.py
"""
import os
from datetime import timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session
from app.db import Base
import app.models # noqa: F401
from app.models.tables import (Account, ProjectionPoint, ProjectionResult,
Scenario, Transaction)
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS, create_views
PG_URL = os.environ.get("FB_TEST_PG_URL")
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")
def _reset(engine):
with engine.begin() as conn:
for name in VIEWS:
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
Base.metadata.drop_all(engine)
@pytest.fixture
def pg():
assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
engine = create_engine(PG_URL)
_reset(engine)
Base.metadata.create_all(engine)
create_views(engine)
with Session(engine) as session:
yield session
_reset(engine)
engine.dispose()
def test_v_projection_ist_serie_und_szenario_ab_heute(pg):
today = pg.execute(text("SELECT CURRENT_DATE")).scalar()
acc = Account(bank="t", iban="DE-PG-1", name="Testkonto", type="giro",
anchor_date=today - timedelta(days=10),
anchor_balance=Decimal("1000.00"))
pg.add(acc)
pg.flush()
pg.add(Transaction(account_id=acc.id, booking_date=today - timedelta(days=2),
amount=Decimal("-100.00"), purpose="t", counterparty="t",
status="confirmed", dedup_hash="pg-1"))
stale = Scenario(name="Veraltet", description="")
empty = Scenario(name="Ohne Punkte", description="")
pg.add_all([stale, empty])
pg.flush()
# Veraltete Momentaufnahme: Punkte von heute-5 bis heute+5.
for off in range(-5, 6):
pg.add(ProjectionPoint(scenario_id=stale.id, day=today + timedelta(days=off),
balance=Decimal("5000.00")))
pg.add(ProjectionResult(scenario_id=stale.id, computed_at=today,
low_point_date=today, low_point_balance=Decimal("0")))
pg.commit()
rows = pg.execute(text(
"SELECT scenario, day, balance FROM v_projection ORDER BY scenario, day")).all()
ist = [r for r in rows if r.scenario == RESERVED_SCENARIO_NAME]
sc = [r for r in rows if r.scenario == "Veraltet"]
assert max(r.day for r in ist) == today
assert [r.balance for r in ist if r.day == today] == [Decimal("900.00")]
# Szenario startet heute am Ist-Saldo, keine alten Punkte davor.
assert sc[0].day == today and sc[0].balance == Decimal("900.00")
assert all(r.day >= today for r in sc)
assert [r.balance for r in sc[1:]] == [Decimal("5000.00")] * 5
assert not any(r.scenario == "Ohne Punkte" for r in rows)