Die Zuordnung war unbrauchbar: "Mehl Typ 405" landete auf "Muehlen
Geschnetzeltes", "Eier" auf "Reis", "Zucker" auf "Joystick fuer WII",
"Apfel" auf "Snack Collection Platten 4 (Waffeln)" und "Salz" auf "Shirt
Casual Fit". Drei Fehler steckten darin.
1. Gegenstaende standen ueberhaupt zur Auswahl. Der Kassenzettel-Abgleich
filtert sie seit immer heraus, mein Rezeptabgleich nicht - also konnten
ein Joystick, ein Shirt und eine Waffelplatte als Zutat gelten. Jetzt
zaehlen nur Lebensmittel, und Einzelstuecke bleiben ebenfalls draussen.
2. Entschieden hat eine Buchstaben-Aehnlichkeit. Die haelt "Eier" und
"Reis" fuer verwandt, weil sich Zeichen ueberlappen. Neu muss ein ganzes
Wort geteilt sein: "Mehl" in "Weissmehl Typ 550" ja, "Mehl" in "Muehlen
Geschnetzeltes" nein. Umlaute und Mehrzahl werden dabei zusammengezogen,
damit "Apfel" auch "Aepfel" findet.
3. Gesucht wurde im ganzen Katalog. Jetzt zuerst dort, wo es hingehoert:
Teilt der Zutatenname ein Wort mit einer Kategorie, gilt nur deren
Teilbaum - "Mehl Typ 405" sucht in "Mehl & Zucker", nicht im Fleisch.
Erst wenn darin nichts passt, zaehlt der Rest.
Die Punktzahl ist damit keine Sperre mehr, sondern nur noch die
Reihenfolge. Als Sperre war sie untauglich in beide Richtungen: "Vollmilch
Zimmertemperatur" gegen "H Vollmilch" kommt auf etwa 40 Punkte, weil das
lange Beiwort den Durchschnitt zieht - offensichtlich dasselbe Produkt.
"Mehl" gegen "Muehlen Geschnetzeltes" kam dagegen ueber 60.
Folge, die so gewollt ist: Wo nichts passt, steht jetzt "nicht zugeordnet"
und gar kein Vorschlag mehr. Ein Vorschlag, den niemand nachvollziehen
kann, ist schlimmer als keiner - er verleitet zum Durchklicken. Die
dauerhafte Loesung fuer echte Luecken ist die gemerkte Zuordnung
("Mealie-Lebensmittel X ist bei uns Y"), die noch aussteht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""Rezeptzutaten gegen den Bestand: Kann ich das kochen?
|
||
|
||
Ohne Mealie – die Zutaten stehen hier so im Test, wie Mealie sie liefern würde.
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from app.models import (
|
||
BaseUnit,
|
||
Category,
|
||
CategoryMeasure,
|
||
CategoryTracking,
|
||
Group,
|
||
Measure,
|
||
Product,
|
||
)
|
||
from app.services.rezepte import faktor_fuer_portionen, kochbar, pruefe_zutaten
|
||
from app.services.stock import check_in
|
||
|
||
|
||
def _zutat(name, menge=None, einheit=None, text=None):
|
||
"""Eine Zutat im Format von Mealie."""
|
||
return {
|
||
"quantity": menge,
|
||
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
|
||
"food": {"name": name},
|
||
"display": text or f"{menge or ''} {einheit or ''} {name}".strip(),
|
||
}
|
||
|
||
|
||
def _kategorie(db, name):
|
||
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):
|
||
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)
|
||
if bestand:
|
||
check_in(db, product, bestand, base.value, None, None, None)
|
||
db.commit()
|
||
return product
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Portionen
|
||
# --------------------------------------------------------------------------
|
||
def test_portionen_skalieren_die_mengen(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=5000)
|
||
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 4}, 6)
|
||
|
||
assert faktor == 1.5
|
||
assert hinweis == ""
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")], faktor=faktor)
|
||
assert zutat.menge == 300
|
||
assert zutat.benoetigt_base == 300
|
||
|
||
|
||
def test_ohne_portionsangabe_wird_nicht_skaliert(db):
|
||
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 0}, 6)
|
||
|
||
assert faktor == 1.0
|
||
assert "keine Portionszahl" in hinweis
|
||
|
||
|
||
def test_ohne_wunsch_bleibt_es_wie_im_rezept(db):
|
||
assert faktor_fuer_portionen({"recipeServings": 4}, None) == (1.0, "")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Reicht der Bestand?
|
||
# --------------------------------------------------------------------------
|
||
def test_genug_vorhanden(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
|
||
|
||
assert zutat.status == "vorhanden"
|
||
assert zutat.fehlt_base == 0
|
||
assert kochbar([zutat]) is True
|
||
|
||
|
||
def test_zu_wenig_meldet_die_fehlmenge(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=150)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
|
||
|
||
assert zutat.status == "zu_wenig"
|
||
assert zutat.fehlt_base == 350
|
||
assert kochbar([zutat]) is False
|
||
|
||
|
||
def test_gar_nichts_da(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
|
||
|
||
assert zutat.status == "fehlt"
|
||
assert zutat.fehlt_base == 500
|
||
|
||
|
||
def test_unbekannte_zutat_wird_nicht_still_als_vorhanden_gewertet(db):
|
||
_kategorie(db, "Trockenwaren")
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Safranfäden", 1, "g")])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
assert kochbar([zutat]) is False
|
||
|
||
|
||
def test_zutat_ohne_menge_ist_unklar(db):
|
||
cat = _kategorie(db, "Gewürze")
|
||
_artikel(db, "Salz", cat, bestand=500)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None, text="Salz nach Geschmack")])
|
||
|
||
assert zutat.status == "menge_unbekannt"
|
||
assert kochbar([zutat]) is False
|
||
|
||
|
||
def test_ohne_menge_und_ohne_bestand_fehlt_es(db):
|
||
cat = _kategorie(db, "Gewürze")
|
||
_artikel(db, "Salz", cat, bestand=0)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None)])
|
||
|
||
assert zutat.status == "fehlt"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Zuordnung
|
||
# --------------------------------------------------------------------------
|
||
def test_aehnlicher_name_wird_gefunden(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl Typ 550", cat, bestand=2000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
|
||
|
||
assert zutat.art == "product"
|
||
assert zutat.ziel_name == "Weissmehl Typ 550"
|
||
|
||
|
||
def test_gruppe_zaehlt_marken_zusammen(db):
|
||
"""„Milch" ist markenuebergreifend gemeint – dafuer gibt es Gruppen."""
|
||
gruppe = Group(name="Milch")
|
||
db.add(gruppe)
|
||
db.commit()
|
||
cat = _kategorie(db, "Molkerei")
|
||
a = _artikel(db, "Valflora Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
|
||
b = _artikel(db, "Emmi Vollmilch", cat, base=BaseUnit.milliliter, bestand=500)
|
||
a.group_id = gruppe.id
|
||
b.group_id = gruppe.id
|
||
db.commit()
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Milch", 1200, "ml")])
|
||
|
||
assert zutat.art == "group"
|
||
assert zutat.bestand_base == 1500 # beide Marken zusammen
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
def test_gemerkte_zuordnung_schlaegt_die_suche(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
richtig = _artikel(db, "Dinkelmehl", cat, bestand=2000)
|
||
|
||
[zutat] = pruefe_zutaten(
|
||
db, [_zutat("Mehl", 100, "g")],
|
||
zuordnung={"mehl": ("product", richtig.id)},
|
||
)
|
||
|
||
assert zutat.ziel_id == richtig.id
|
||
assert zutat.ziel_name == "Dinkelmehl"
|
||
|
||
|
||
def test_voellig_fremde_zutat_liefert_gar_keinen_vorschlag(db):
|
||
"""Lieber nichts als Unsinn.
|
||
|
||
Fruehere Fassung schlug hier „Weissmehl" vor, weil sich Buchstaben
|
||
ueberlappen. Ein Vorschlag, den niemand nachvollziehen kann, ist schlimmer
|
||
als keiner – er verleitet zum Durchklicken.
|
||
"""
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Kurkuma", 1, "g")])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
assert zutat.ziel_id is None
|
||
assert zutat.kandidaten == []
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Was NICHT passen darf – die Faelle aus dem echten Katalog
|
||
# --------------------------------------------------------------------------
|
||
def test_gegenstaende_kommen_nicht_in_frage(db):
|
||
"""Ein Joystick ist kein Zucker und eine Waffelplatte kein Apfel."""
|
||
essen = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", essen, bestand=1000)
|
||
kram = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||
db.add(kram)
|
||
db.commit()
|
||
db.refresh(kram)
|
||
for name in ("Joystick für WII", "Snack Collection Platten 4 (Waffeln)",
|
||
"Shirt Casual Fit"):
|
||
db.add(Product(name=name, base_unit=BaseUnit.piece, category_id=kram.id,
|
||
source="manual"))
|
||
db.commit()
|
||
|
||
for zutat_name in ("Zucker", "Apfel", "Salz"):
|
||
[zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 1, "g")])
|
||
assert zutat.status == "kein_treffer", zutat_name
|
||
assert all("Joystick" not in k.name for k in zutat.kandidaten)
|
||
|
||
|
||
def test_mehl_trifft_nicht_das_geschnetzelte(db):
|
||
"""Buchstaben-Aehnlichkeit allein reicht nicht – es braucht ein ganzes Wort."""
|
||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
|
||
|
||
def test_eier_treffen_nicht_den_reis(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Reis", cat, bestand=2000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Eier", 3, None)])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
|
||
|
||
def test_kategorie_wird_zuerst_durchsucht(db):
|
||
"""Steht die Zutat in einer passenden Kategorie, gilt nur die."""
|
||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
|
||
backen = _kategorie(db, "Mehl & Zucker")
|
||
richtig = _artikel(db, "Weissmehl Typ 405", backen, bestand=2000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
|
||
|
||
assert zutat.ziel_id == richtig.id
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
def test_umlaut_und_mehrzahl_finden_sich(db):
|
||
"""Rezept schreibt „Apfel", im Katalog stehen „Äpfel"."""
|
||
cat = _kategorie(db, "Obst & Gemüse")
|
||
aepfel = _artikel(db, "Äpfel", cat, base=BaseUnit.gram, bestand=2000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Apfel", 900, "g")])
|
||
|
||
assert zutat.ziel_id == aepfel.id
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
def test_vollmilch_findet_die_h_vollmilch(db):
|
||
cat = _kategorie(db, "Molkerei")
|
||
milch = _artikel(db, "H Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Vollmilch Zimmertemperatur", 120, "ml")])
|
||
|
||
assert zutat.ziel_id == milch.id
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Hausmaße
|
||
# --------------------------------------------------------------------------
|
||
def test_essloeffel_wird_umgerechnet(db):
|
||
cat = _kategorie(db, "Mehl")
|
||
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
|
||
el = db.query(Measure).filter(Measure.name == "Esslöffel").one()
|
||
db.add(CategoryMeasure(measure_id=el.id, category_id=cat.id, amount=10,
|
||
base=BaseUnit.gram.value))
|
||
db.commit()
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 2, "EL")])
|
||
|
||
assert zutat.benoetigt_base == 20
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
def test_fehlendes_mass_wird_zum_anlegen_gemeldet(db):
|
||
"""Daraus baut die Oberflaeche „Maß jetzt anlegen"."""
|
||
cat = _kategorie(db, "Hülsenfrüchte")
|
||
_artikel(db, "Berglinsen", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
|
||
|
||
assert zutat.status == "menge_unbekannt"
|
||
assert zutat.fehlendes_mass == "Tasse"
|
||
assert zutat.kategorie_id == cat.id
|
||
assert "kein Maß" in zutat.hinweis
|
||
|
||
|
||
def test_unbekannte_einheit_wird_benannt(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 1, "furlong")])
|
||
|
||
assert zutat.status == "menge_unbekannt"
|
||
assert "furlong" in zutat.hinweis
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Ganzes Rezept
|
||
# --------------------------------------------------------------------------
|
||
def test_ein_rezept_mit_allem(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
_artikel(db, "Zucker", cat, bestand=50)
|
||
|
||
zutaten = [
|
||
_zutat("Weissmehl", 200, "g"), # reicht
|
||
_zutat("Zucker", 100, "g"), # zu wenig
|
||
_zutat("Safran", 1, "g"), # kennt niemand
|
||
]
|
||
ergebnis = pruefe_zutaten(db, zutaten)
|
||
|
||
assert [z.status for z in ergebnis] == ["vorhanden", "zu_wenig", "kein_treffer"]
|
||
assert kochbar(ergebnis) is False
|