Zeitraum-Auswahl: Sie stand ueber dem Diagramm und nahm ihm Hoehe weg. Jetzt sitzt sie klein im Kartenkopf neben dem Titel - die ganze Kartenhoehe gehoert dem Diagramm. Die Wahl wird in der Anordnung mitgespeichert, bleibt also nach dem Neuladen erhalten; ausserhalb des Bearbeitungsmodus geschieht das still im Hintergrund. Feinere Aufloesung: Ein Punkt je Tag verbarg, WANN ein- und ausgelagert wurde. Die Abtastrate richtet sich jetzt nach dem Zeitraum: bis 2 Tage -> stuendlich bis 14 Tage -> alle 6 Stunden bis 120 Tage -> taeglich darueber -> woechentlich So bleibt die Punktzahl immer zwischen etwa 25 und 105 - fein genug zum Erkennen, grob genug zum Zeichnen. Ein Test haelt diese Spanne fest. Die Zeitachse traegt entsprechend Uhrzeit statt Datum, wenn stuendlich abgetastet wird. Punkte heissen jetzt "at" (Zeitpunkt) statt "date". Zeitstempel aus SQLite kommen ohne Zeitzone zurueck und werden vereinheitlicht, sonst schluege die Differenzbildung fehl. Auswahl erweitert: 24 Stunden, 2 Tage, 7, 30, 90 Tage, 6 Monate, 1 Jahr. Geprueft: "npm run build" laeuft durch. pytest weiterhin nicht ausfuehrbar - kein Python auf diesem Rechner; die Tests wurden an die neue Aufloesung angepasst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
7.7 KiB
Python
204 lines
7.7 KiB
Python
from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Tests)
|
||
|
||
import pytest
|
||
|
||
from app.models import BaseUnit, Category, Product, Role, User
|
||
from app.routers import dashboard
|
||
from app.services.conversion import article_unit, article_units
|
||
from app.services.stock import check_in, check_out
|
||
|
||
|
||
@pytest.fixture()
|
||
def user(db):
|
||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||
db.add(person)
|
||
db.commit()
|
||
db.refresh(person)
|
||
return person
|
||
|
||
|
||
# ---------------------------------------------------------------- Einheiten
|
||
def test_artikeleinheit_ist_das_gebinde(rice):
|
||
# rice: 500 g je Packung -> eine Artikeleinheit sind 500 g
|
||
faktor, label = article_unit(rice)
|
||
assert faktor == 500
|
||
assert label == "Packung"
|
||
assert article_units(rice, 1500) == 3
|
||
|
||
|
||
def test_artikeleinheit_ohne_gebinde_ist_die_produkteinheit(db):
|
||
eier = Product(name="Eier", base_unit=BaseUnit.piece)
|
||
db.add(eier)
|
||
db.commit()
|
||
faktor, _ = article_unit(eier)
|
||
assert faktor == 1
|
||
assert article_units(eier, 6) == 6
|
||
|
||
|
||
def test_gebinde_bezeichnung_wird_uebernommen(db):
|
||
pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
|
||
package_label="Glas")
|
||
db.add(pesto)
|
||
db.commit()
|
||
assert article_unit(pesto) == (195, "Glas")
|
||
|
||
|
||
# ------------------------------------------------------------ Ablaufzustand
|
||
def test_zustand_unterscheidet_die_vier_faelle():
|
||
heute = date(2026, 7, 1)
|
||
assert dashboard._zustand(None, heute, 7) == "no_date"
|
||
assert dashboard._zustand(heute - timedelta(days=1), heute, 7) == "expired"
|
||
assert dashboard._zustand(heute + timedelta(days=3), heute, 7) == "soon"
|
||
assert dashboard._zustand(heute, heute, 7) == "soon" # heute ist noch nicht abgelaufen
|
||
assert dashboard._zustand(heute + timedelta(days=30), heute, 7) == "ok"
|
||
|
||
|
||
def test_expiry_split_zaehlt_in_artikeleinheiten(db, rice, user):
|
||
heute = date.today()
|
||
check_in(db, rice, 2, "package", heute + timedelta(days=60), None, user) # ok
|
||
check_in(db, rice, 1, "package", heute + timedelta(days=3), None, user) # bald
|
||
check_in(db, rice, 3, "package", heute - timedelta(days=2), None, user) # abgelaufen
|
||
check_in(db, rice, 4, "package", None, None, user) # ohne MHD
|
||
db.commit()
|
||
|
||
split = dashboard.expiry_split(db=db, _=user)
|
||
assert split.ok == 2
|
||
assert split.soon == 1
|
||
assert split.expired == 3
|
||
assert split.no_date == 4
|
||
|
||
|
||
def test_by_category_bündelt_und_sortiert(db, user):
|
||
suess = Category(name="Süßwaren")
|
||
db.add(suess)
|
||
db.commit()
|
||
|
||
chips = Product(name="Chips", base_unit=BaseUnit.gram, package_size=175,
|
||
package_label="Tüte", category_id=suess.id)
|
||
ohne = Product(name="Salz", base_unit=BaseUnit.gram, package_size=500)
|
||
db.add_all([chips, ohne])
|
||
db.commit()
|
||
|
||
check_in(db, chips, 5, "package", None, None, user)
|
||
check_in(db, ohne, 2, "package", None, None, user)
|
||
db.commit()
|
||
|
||
anteile = dashboard.by_category(db=db, _=user)
|
||
assert [a.name for a in anteile] == ["Süßwaren", "Ohne Kategorie"]
|
||
assert anteile[0].article_units == 5
|
||
assert anteile[1].article_units == 2
|
||
|
||
|
||
# ----------------------------------------------------------------- Verlauf
|
||
def test_schrittweite_haengt_am_zeitraum():
|
||
"""Kurze Zeitraeume werden fein abgetastet, lange grob."""
|
||
assert dashboard._schrittweite(1) == timedelta(hours=1)
|
||
assert dashboard._schrittweite(2) == timedelta(hours=1)
|
||
assert dashboard._schrittweite(7) == timedelta(hours=6)
|
||
assert dashboard._schrittweite(90) == timedelta(days=1)
|
||
assert dashboard._schrittweite(365) == timedelta(days=7)
|
||
|
||
|
||
def test_schrittweite_haelt_die_punktzahl_im_rahmen():
|
||
"""Egal welcher Zeitraum – die Kurve bleibt zeichenbar."""
|
||
for tage in (1, 2, 7, 14, 30, 90, 180, 365, 730):
|
||
anzahl = timedelta(days=tage) / dashboard._schrittweite(tage)
|
||
assert 20 <= anzahl <= 180, f"{tage} Tage ergeben {anzahl} Punkte"
|
||
|
||
|
||
def test_timeline_rechnet_bewegungen_zurueck(db, rice, user):
|
||
"""Der Verlauf entsteht rueckwaerts aus dem heutigen Bestand."""
|
||
check_in(db, rice, 4, "package", None, None, user) # +4 Einheiten jetzt
|
||
check_out(db, rice, 1, "package", user) # -1 Einheit jetzt
|
||
db.commit()
|
||
|
||
punkte = dashboard.timeline(days=3, product_id=None, db=db, _=user)
|
||
# Jetzt 3 Einheiten; vor dem Einlagern war das Lager leer.
|
||
assert punkte[-1].article_units == 3
|
||
assert punkte[0].article_units == 0
|
||
# Aufsteigend sortiert, damit die Linie von links nach rechts laeuft.
|
||
assert punkte[0].at < punkte[-1].at
|
||
|
||
|
||
def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user):
|
||
anderes = Product(name="Zucker", base_unit=BaseUnit.gram, package_size=1000)
|
||
db.add(anderes)
|
||
db.commit()
|
||
check_in(db, rice, 2, "package", None, None, user)
|
||
check_in(db, anderes, 5, "package", None, None, user)
|
||
db.commit()
|
||
|
||
gesamt = dashboard.timeline(days=2, product_id=None, db=db, _=user)
|
||
nur_reis = dashboard.timeline(days=2, product_id=rice.id, db=db, _=user)
|
||
assert gesamt[-1].article_units == 7
|
||
assert nur_reis[-1].article_units == 2
|
||
|
||
|
||
def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
||
check_in(db, rice, 2, "package", None, None, user)
|
||
check_in(db, rice, 1, "package", None, None, user)
|
||
check_out(db, rice, 1, "package", user)
|
||
db.commit()
|
||
|
||
abschnitte = dashboard.activity(days=3, db=db, _=user)
|
||
# Alles gerade eben passiert -> liegt im juengsten Abschnitt.
|
||
juengster = abschnitte[-1]
|
||
assert juengster.checked_in == 2
|
||
assert juengster.checked_out == 1
|
||
assert sum(a.checked_in for a in abschnitte) == 2
|
||
assert abschnitte[0].at < juengster.at
|
||
|
||
|
||
# ---------------------------------------------------------------- Anordnung
|
||
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
|
||
antwort = dashboard.get_layout(db=db, user=user)
|
||
assert antwort.source == "builtin"
|
||
assert antwort.enforced is False
|
||
assert antwort.has_default is False
|
||
assert any(k["i"] == "expiring" for k in antwort.layout)
|
||
|
||
|
||
def test_eigene_anordnung_wird_gespeichert(db, user):
|
||
from app.schemas import DashboardLayoutIn
|
||
|
||
eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
||
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
|
||
|
||
antwort = dashboard.get_layout(db=db, user=user)
|
||
assert antwort.source == "user"
|
||
assert antwort.layout == eigen
|
||
|
||
|
||
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
|
||
from fastapi import HTTPException
|
||
|
||
from app.schemas import DashboardLayoutIn
|
||
|
||
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
||
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
||
dashboard.set_enforced(True, db=db, admin=user)
|
||
|
||
antwort = dashboard.get_layout(db=db, user=user)
|
||
assert antwort.source == "default"
|
||
assert antwort.enforced is True
|
||
assert antwort.layout == vorgabe
|
||
|
||
with pytest.raises(HTTPException) as fehler:
|
||
dashboard.put_layout(DashboardLayoutIn(layout=[]), db=db, user=user)
|
||
assert fehler.value.status_code == 403
|
||
|
||
|
||
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
|
||
from app.schemas import DashboardLayoutIn
|
||
|
||
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
||
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
||
dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]),
|
||
db=db, user=user)
|
||
assert dashboard.get_layout(db=db, user=user).source == "user"
|
||
|
||
dashboard.reset_layout(db=db, user=user)
|
||
antwort = dashboard.get_layout(db=db, user=user)
|
||
assert antwort.source == "default"
|
||
assert antwort.layout == vorgabe
|