Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept
Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.
Zwei Tabellen dafuer, und die Trennung ist der Kern:
shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.
shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.
Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.
Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.
In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.
Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f02e9a3840
commit
795e233a1b
@@ -0,0 +1,290 @@
|
||||
"""Die Einkaufsliste bekommt ein Gedächtnis: eigene Einträge, Haken, Rezepte.
|
||||
|
||||
Bisher war die Liste vollständig gerechnet. Was hier geprüft wird, ist das
|
||||
Daneben: Dinge, die jemand selbst draufgesetzt hat, das dauerhafte Abhaken, und
|
||||
die Übernahme der fehlenden Zutaten eines Rezepts — ohne Mealie, die Zutaten
|
||||
stehen direkt im Test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Product,
|
||||
ProductLocationMinStock,
|
||||
Role,
|
||||
ShoppingCheck,
|
||||
ShoppingEntry,
|
||||
User,
|
||||
)
|
||||
from app.routers.shopping import (
|
||||
clear_check,
|
||||
clear_done,
|
||||
create_entry,
|
||||
delete_entry,
|
||||
list_entries,
|
||||
set_check,
|
||||
update_entry,
|
||||
)
|
||||
from app.routers.views import shopping_list_all
|
||||
from app.schemas import ShoppingEntryIn, ShoppingEntryPatch
|
||||
from app.services import einkaufsliste
|
||||
from app.services.rezepte import pruefe_zutaten
|
||||
from app.services.stock import check_in
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nutzer(db):
|
||||
person = User(username="einkaeufer", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _zutat(name, menge=None, einheit=None):
|
||||
return {
|
||||
"quantity": menge,
|
||||
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
|
||||
"food": {"name": name},
|
||||
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
|
||||
}
|
||||
|
||||
|
||||
def _kategorie(db, name="Vorrat"):
|
||||
cat = Category(name=name, 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, bestand=0.0):
|
||||
produkt = Product(name=name, base_unit=base, package_size=package_size,
|
||||
category_id=cat.id, source="manual")
|
||||
db.add(produkt)
|
||||
db.commit()
|
||||
db.refresh(produkt)
|
||||
if bestand:
|
||||
check_in(db, produkt, bestand, base.value, None, None, None)
|
||||
db.commit()
|
||||
return produkt
|
||||
|
||||
|
||||
def _uebernehmen(db, zutaten, nutzer, *, slug="pfannkuchen", name="Pfannkuchen"):
|
||||
"""Wie der Endpunkt, nur ohne Mealie davor."""
|
||||
pruefungen = pruefe_zutaten(db, zutaten)
|
||||
return einkaufsliste.aus_rezept(db, pruefungen, slug=slug, name=name, user=nutzer)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Eigene Einträge
|
||||
# --------------------------------------------------------------------------
|
||||
def test_freitext_landet_auf_der_liste(db, nutzer):
|
||||
"""Der Fall, für den es bisher keinen Ort gab: 'Milch, weil ich das sage'."""
|
||||
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
|
||||
|
||||
[eintrag] = list_entries(db=db, _=nutzer)
|
||||
|
||||
assert eintrag.art == "text"
|
||||
assert eintrag.name == "Backpapier"
|
||||
assert eintrag.done is False
|
||||
|
||||
|
||||
def test_artikel_eintrag_nennt_die_menge_lesbar(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
|
||||
eintrag = create_entry(
|
||||
ShoppingEntryIn(product_id=mehl.id, quantity_base=1500, base=BaseUnit.gram),
|
||||
db=db, user=nutzer,
|
||||
)
|
||||
|
||||
assert eintrag.name == "Weissmehl"
|
||||
# Man kauft keine halbe Packung - dieselbe Sprache wie die gerechnete Liste.
|
||||
assert eintrag.menge_text == "2 Packungen"
|
||||
|
||||
|
||||
def test_genau_ein_ziel_ist_pflicht(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
|
||||
with pytest.raises(HTTPException) as leer:
|
||||
create_entry(ShoppingEntryIn(), db=db, user=nutzer)
|
||||
assert leer.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as doppelt:
|
||||
create_entry(
|
||||
ShoppingEntryIn(product_id=mehl.id, free_text="Mehl"), db=db, user=nutzer,
|
||||
)
|
||||
assert doppelt.value.status_code == 400
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
def test_abhaken_haelt_das_neuladen_aus(db, nutzer):
|
||||
"""Der eigentliche Punkt: Im Supermarkt wird die Seite neu geladen."""
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
|
||||
|
||||
[frisch] = list_entries(db=db, _=nutzer)
|
||||
assert frisch.done is True
|
||||
assert db.get(ShoppingEntry, eintrag.id).done_at is not None
|
||||
|
||||
|
||||
def test_text_eines_artikeleintrags_laesst_sich_nicht_umschreiben(db, nutzer):
|
||||
"""Sonst stünde im Eintrag ein Name, der nichts mit dem Artikel zu tun hat."""
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
eintrag = create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(free_text="Zucker"), db=db, _=nutzer)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
def test_eintrag_auf_einen_geloeschten_artikel_faellt_heraus(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
|
||||
|
||||
db.delete(mehl)
|
||||
db.commit()
|
||||
|
||||
assert list_entries(db=db, _=nutzer) == []
|
||||
|
||||
|
||||
def test_loeschen_entfernt_den_eintrag(db, nutzer):
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
|
||||
delete_entry(eintrag.id, db=db, _=nutzer)
|
||||
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Haken an den gerechneten Posten
|
||||
# --------------------------------------------------------------------------
|
||||
def test_haken_am_gerechneten_posten_bleibt(db, nutzer):
|
||||
"""Gerechnete Posten haben keine Zeile - gemerkt wird nur der Haken."""
|
||||
set_check("p12", db=db, user=nutzer)
|
||||
|
||||
assert einkaufsliste.abgehakte(db) == ["p12"]
|
||||
|
||||
clear_check("p12", db=db, _=nutzer)
|
||||
assert einkaufsliste.abgehakte(db) == []
|
||||
|
||||
|
||||
def test_derselbe_haken_zweimal_ist_kein_fehler(db, nutzer):
|
||||
set_check("g3", db=db, user=nutzer)
|
||||
set_check("g3", db=db, user=nutzer)
|
||||
|
||||
assert db.query(ShoppingCheck).count() == 1
|
||||
|
||||
|
||||
def test_erledigte_entfernen_raeumt_beide_quellen(db, nutzer):
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
|
||||
offen = create_entry(ShoppingEntryIn(free_text="Salz"), db=db, user=nutzer)
|
||||
set_check("p12", db=db, user=nutzer)
|
||||
|
||||
anzahl = clear_done(db=db, _=nutzer)
|
||||
|
||||
assert anzahl == 2
|
||||
assert [e.id for e in list_entries(db=db, _=nutzer)] == [offen.id]
|
||||
assert einkaufsliste.abgehakte(db) == []
|
||||
|
||||
|
||||
def test_die_gebuendelte_liste_fuehrt_beides(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat, bestand=200)
|
||||
db.add(ProductLocationMinStock(product_id=mehl.id, location_id=None, min_stock=1000))
|
||||
db.commit()
|
||||
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
|
||||
set_check(f"p{mehl.id}", db=db, user=nutzer)
|
||||
|
||||
alles = shopping_list_all(db=db, user=nutzer)
|
||||
|
||||
assert [i.name for i in alles.products] == ["Weissmehl"]
|
||||
assert [e.name for e in alles.entries] == ["Backpapier"]
|
||||
assert alles.checked == [f"p{mehl.id}"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aus einem Rezept übernehmen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_uebernommen_wird_die_fehlmenge_nicht_die_rezeptmenge(db, nutzer):
|
||||
"""Wer 250 g braucht und 100 g hat, kauft 150 g."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=100)
|
||||
|
||||
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert aktualisiert == 0
|
||||
[eintrag] = eintraege
|
||||
assert eintrag.quantity_base == 150
|
||||
assert eintrag.source == "recipe"
|
||||
assert eintrag.recipe_name == "Pfannkuchen"
|
||||
|
||||
|
||||
def test_vorhandene_zutaten_kommen_nicht_drauf(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=5000)
|
||||
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert eintraege == []
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
def test_zutat_ohne_zuordnung_kommt_als_freitext_drauf(db, nutzer):
|
||||
"""Gerade die unbekannte Zutat ist die, die man vergisst."""
|
||||
_kategorie(db)
|
||||
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Ras el Hanout", 1, "Prise")], nutzer)
|
||||
|
||||
[eintrag] = eintraege
|
||||
assert eintrag.free_text == "Ras el Hanout"
|
||||
assert eintrag.product_id is None
|
||||
|
||||
|
||||
def test_zweimal_uebernehmen_verdoppelt_die_liste_nicht(db, nutzer):
|
||||
"""Nach dem Hochdrehen der Portionen drückt man den Knopf noch einmal."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=100)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 500, "g")], nutzer)
|
||||
|
||||
assert aktualisiert == 1
|
||||
assert db.query(ShoppingEntry).count() == 1
|
||||
assert eintraege[0].quantity_base == 400 # 500 gebraucht, 100 da
|
||||
|
||||
|
||||
def test_zwei_rezepte_brauchen_denselben_artikel_zweimal(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 300, "g")], nutzer,
|
||||
slug="brot", name="Brot")
|
||||
|
||||
zeilen = db.query(ShoppingEntry).all()
|
||||
assert len(zeilen) == 2
|
||||
assert {z.recipe_name for z in zeilen} == {"Pfannkuchen", "Brot"}
|
||||
|
||||
|
||||
def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer):
|
||||
"""Abgehakt heisst gekauft – braucht man es wieder, ist es eine neue Zeile."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
einkaufsliste.abhaken(db, db.get(ShoppingEntry, eintraege[0].id), True)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert db.query(ShoppingEntry).count() == 2
|
||||
Reference in New Issue
Block a user