Files
ScarriffleandClaude Opus 5 f61c76034c Hausmasse und Oeffnungsfrist bekommen eine Oberflaeche
Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.

Zwei Stellen dafuer:

Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.

Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.

Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.

Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.

4 neue Tests, Suite bei 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:37:53 +02:00

312 lines
11 KiB
Python
Raw Permalink 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.
"""Hausmaße: „2 EL" wiegt bei Mehl anders als bei Zucker.
Die Menge gehört an die Kategorie bzw. den Artikel, nicht an den Namen – ein
globaler Faktor wie bei den verwalteten Einheiten wäre hier zwangsläufig falsch.
"""
import pytest
from app.models import (
BaseUnit,
Category,
CategoryMeasure,
CategoryTracking,
Measure,
Product,
ProductMeasure,
)
from app.routers.measures import category_measures
from app.seed import ensure_builtin_measures
from app.services.conversion import ConversionError, to_base
from app.services.stock import check_in, check_out, current_stock
from app.services.vererbung import (
geerbte_kategorie_frist,
mass_aufloesen,
verfuegbare_masse,
)
@pytest.fixture()
def masse(db):
ensure_builtin_measures(db)
return {m.name: m for m in db.query(Measure).all()}
def _kategorie(db, name, parent=None):
cat = Category(
name=name,
parent_id=parent.id if parent else None,
tracking=CategoryTracking.food.value,
)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000):
product = Product(
name=name, base_unit=base, package_size=package_size,
category_id=cat.id, source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _mass_an_kategorie(db, measure, cat, amount, base=BaseUnit.gram):
db.add(CategoryMeasure(
measure_id=measure.id, category_id=cat.id, amount=amount, base=base.value
))
db.commit()
# --------------------------------------------------------------------------
# Derselbe Löffel, zwei Gewichte
# --------------------------------------------------------------------------
def test_esslöffel_wiegt_je_kategorie_anders(db, masse):
el = masse["Esslöffel"]
mehl_kat = _kategorie(db, "Mehl")
zucker_kat = _kategorie(db, "Zucker")
mehl = _artikel(db, "Weissmehl", mehl_kat)
zucker = _artikel(db, "Kristallzucker", zucker_kat)
_mass_an_kategorie(db, el, mehl_kat, 10)
_mass_an_kategorie(db, el, zucker_kat, 12)
assert to_base(db, mehl, 2, "EL", hausmasse=True) == 20
assert to_base(db, zucker, 2, "EL", hausmasse=True) == 24
def test_kurzform_und_grossschreibung(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
for token in ("EL", "el", "Esslöffel", "esslöffel", " EL "):
assert to_base(db, mehl, 1, token, hausmasse=True) == 10
def test_tasse_in_millilitern(db, masse):
tasse = masse["Tasse"]
kat = _kategorie(db, "Molkerei")
milch = _artikel(db, "Vollmilch", kat, base=BaseUnit.milliliter)
_mass_an_kategorie(db, tasse, kat, 200, base=BaseUnit.milliliter)
assert to_base(db, milch, 1.5, "Tasse", hausmasse=True) == 300
# --------------------------------------------------------------------------
# Vererbung: Artikel > nächste Kategorie > Oberkategorie
# --------------------------------------------------------------------------
def test_unterkategorie_erbt_von_oben(db, masse):
el = masse["Esslöffel"]
trocken = _kategorie(db, "Trockenwaren")
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
mehl = _artikel(db, "Weissmehl", mehl_kat)
_mass_an_kategorie(db, el, trocken, 11)
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 11
angabe = mass_aufloesen(db, mehl, "EL")
assert angabe.herkunft == "Kategorie: Trockenwaren"
def test_naehere_kategorie_schlaegt_die_obere(db, masse):
el = masse["Esslöffel"]
trocken = _kategorie(db, "Trockenwaren")
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
mehl = _artikel(db, "Weissmehl", mehl_kat)
_mass_an_kategorie(db, el, trocken, 11)
_mass_an_kategorie(db, el, mehl_kat, 9)
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 9
def test_artikel_schlaegt_die_kategorie(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Zucker")
puder = _artikel(db, "Puderzucker", kat)
_mass_an_kategorie(db, el, kat, 12)
db.add(ProductMeasure(
measure_id=el.id, product_id=puder.id, amount=8, base=BaseUnit.gram.value
))
db.commit()
assert to_base(db, puder, 1, "EL", hausmasse=True) == 8
assert mass_aufloesen(db, puder, "EL").herkunft == "Artikel"
# --------------------------------------------------------------------------
# Lücken und Fehler
# --------------------------------------------------------------------------
def test_mass_ohne_menge_meldet_sich_deutlich(db, masse):
"""Die Meldung ist die Grundlage für „Maß jetzt anlegen" im Rezeptabgleich."""
kat = _kategorie(db, "Hülsenfrüchte")
linsen = _artikel(db, "Berglinsen", kat)
with pytest.raises(ConversionError) as fehler:
to_base(db, linsen, 1, "Tasse", hausmasse=True)
assert "kein Maß 'Tasse' hinterlegt" in str(fehler.value)
def test_voellig_unbekannte_einheit_bleibt_unbekannte_einheit(db, masse):
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
with pytest.raises(ConversionError) as fehler:
to_base(db, mehl, 1, "furlong", hausmasse=True)
assert "Unbekannte Einheit" in str(fehler.value)
def test_artfremdes_mass_braucht_die_zweiteinheit(db, masse):
"""Ein Gramm-Maß an einem Stück-Artikel geht nur über die Brücke."""
el = masse["Esslöffel"]
kat = _kategorie(db, "Backwaren")
broetchen = _artikel(db, "Brötchen", kat, base=BaseUnit.piece, package_size=6)
_mass_an_kategorie(db, el, kat, 20) # in Gramm
with pytest.raises(ConversionError) as fehler:
to_base(db, broetchen, 1, "EL", hausmasse=True)
assert "Zweiteinheit" in str(fehler.value)
# Mit Brücke „3 Stück ≙ 150 g" sind 20 g dann 0,4 Stück.
broetchen.secondary_base = BaseUnit.gram.value
broetchen.secondary_count = 3
broetchen.secondary_amount = 150
db.commit()
assert to_base(db, broetchen, 1, "EL", hausmasse=True) == pytest.approx(0.4)
def test_packung_schlaegt_das_hausmass(db, masse):
"""„package" wird zuerst geprüft – daran darf ein Hausmaß nichts ändern."""
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat, package_size=1000)
neu = Measure(name="Packung", short="Pck", position=99)
db.add(neu)
db.commit()
_mass_an_kategorie(db, neu, kat, 7)
assert to_base(db, mehl, 1, "package", hausmasse=True) == 1000
def test_ohne_schalter_gilt_das_hausmass_nicht(db, masse):
"""Einlagern und Mindestbestände dürfen keine Schätzwerte annehmen."""
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
with pytest.raises(ConversionError) as fehler:
to_base(db, mehl, 2, "EL") # ohne hausmasse=True
assert "Unbekannte Einheit" in str(fehler.value)
def test_einlagern_nimmt_kein_hausmass(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
with pytest.raises(ConversionError):
check_in(db, mehl, 2, "EL", None, None, None)
def test_auslagern_nimmt_ein_hausmass(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
check_in(db, mehl, 1, "package", None, None, None)
db.commit()
check_out(db, mehl, 2, "EL", None)
db.commit()
assert current_stock(db, mehl.id) == 980
def test_einheit_schlaegt_gleichnamiges_hausmass(db, masse):
"""Ein Maß „Liter" darf die echte Einheit nicht verdecken."""
kat = _kategorie(db, "Getränke")
saft = _artikel(db, "Apfelsaft", kat, base=BaseUnit.milliliter, package_size=1000)
liter_mass = Measure(name="Liter", short="Ltr", position=99)
db.add(liter_mass)
db.commit()
_mass_an_kategorie(db, liter_mass, kat, 5, base=BaseUnit.milliliter)
assert to_base(db, saft, 1, "Liter", hausmasse=True) == 1000
def test_verfuegbare_masse_listet_nur_hinterlegte(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
liste = verfuegbare_masse(db, mehl)
assert [a.measure.name for a in liste] == ["Esslöffel"]
assert liste[0].amount == 10
assert liste[0].herkunft == "Kategorie: Mehl"
def test_artikel_ohne_kategorie_hat_keine_masse(db, masse):
product = Product(name="Irgendwas", base_unit=BaseUnit.gram, source="manual")
db.add(product)
db.commit()
assert verfuegbare_masse(db, product) == []
assert mass_aufloesen(db, product, "EL") is None
# --------------------------------------------------------------------------
# Bearbeitungsansicht: eigene und geerbte Mengen
# --------------------------------------------------------------------------
def test_kategorie_sieht_ihre_eigenen_mengen(db, masse):
"""Ohne Vererbung ist die Liste die reine Bearbeitungsansicht."""
ober = _kategorie(db, "Mehl & Zucker")
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
liste = category_measures(ober.id, db=db, _=None)
assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")]
def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse):
"""Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?"""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
ohne = category_measures(unter.id, db=db, _=None)
mit = category_measures(unter.id, vererbt=True, db=db, _=None)
assert ohne == []
assert [(e.name, e.amount, e.herkunft) for e in mit] == [
("Esslöffel", 10, "Kategorie: Mehl & Zucker"),
]
def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse):
"""Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen."""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
_mass_an_kategorie(db, masse["Esslöffel"], unter, 12)
liste = category_measures(unter.id, vererbt=True, db=db, _=None)
assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")]
def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db):
ober = _kategorie(db, "Molkerei")
unter = _kategorie(db, "Frischmilch", parent=ober)
ober.open_days = 5
db.commit()
assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei")
# Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich.
assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen"