"200 ml Schlagsahne" landete auf "Kraeuter Sahne Haehnchen" - beide teilen
das Wort "Sahne", der Rest ist Zufall. Die Einheit haette es verraten: Das
Haehnchen wird in Gramm gefuehrt, das Rezept sagt Milliliter. Wo sich die
Rezept-Einheit gar nicht umrechnen laesst, wird deshalb nicht mehr
zugeordnet, sondern offen gelassen - lieber "nichts Passendes gefunden"
samt Begruendung als eine falsche Behauptung.
Entscheidend ist, WARUM die Umrechnung scheitert, und nur einer der Gruende
sagt etwas ueber den Artikel:
- Bekannte Einheit falscher Art ohne Zweiteinheit -> spricht gegen den
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
- Unbekanntes Wort oder nicht hinterlegtes Hausmass ("1 Tasse Linsen") ->
sagt gar nichts. Die Linsen koennen voellig richtig sein, es fehlt bloss
die Massangabe, und die laesst sich nachtragen.
- Gar keine Einheit ("3 Eier") -> passt immer.
Die erste Fassung hat das nicht unterschieden und damit "1 Tasse Linsen"
zu "nicht zugeordnet" gemacht, obwohl der Artikel stimmte - die beiden
bestehenden Tests haben das sofort gezeigt.
Bei mehreren Kandidaten gewinnt jetzt der mit passender Einheit, auch gegen
eine hoehere Namensaehnlichkeit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442 lines
15 KiB
Python
442 lines
15 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_vegan_allein_begruendet_keinen_treffer(db):
|
||
"""„vegan" ist eine Eigenschaft, kein Lebensmittel.
|
||
|
||
Sonst passt jedes vegane Produkt auf jede vegane Zutat: „vegane Butter"
|
||
landete auf „veganes Mühlen Knusper Bleu", „veganer Lime Curd" auf
|
||
„Vegane Mühlen-Schnitzel".
|
||
"""
|
||
fleisch = _kategorie(db, "Fleischersatz")
|
||
_artikel(db, "veganes Mühlen Knusper Bleu", fleisch, bestand=400)
|
||
_artikel(db, "Vegane Mühlen Nuggets Klassisch", fleisch, bestand=400)
|
||
_artikel(db, "Vegane Mühlen-Schnitzel auf Basis von Soja", fleisch, bestand=400)
|
||
|
||
zutaten = [
|
||
_zutat("vegane Butter, Block, ungesalzen", 45, "g"),
|
||
_zutat("veganer griechischer Joghurt oder Skyr", 80, "g"),
|
||
_zutat("veganer Lime Curd", 120, "g"),
|
||
]
|
||
for zutat in pruefe_zutaten(db, zutaten):
|
||
assert zutat.status == "kein_treffer", zutat.text
|
||
assert zutat.ziel_name == ""
|
||
|
||
|
||
def test_vegane_zutat_findet_das_passende_vegane_produkt(db):
|
||
"""Die Eigenschaft darf nicht sperren, nur nicht allein tragen."""
|
||
molk = _kategorie(db, "Molkereiprodukte")
|
||
butter = _artikel(db, "Vegane Butter", molk, bestand=500)
|
||
_artikel(db, "Vegane Mühlen-Schnitzel", molk, bestand=400)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("vegane Butter, Block, ungesalzen", 45, "g")])
|
||
|
||
assert zutat.ziel_id == butter.id
|
||
assert zutat.status == "vorhanden"
|
||
|
||
|
||
def test_zutat_aus_lauter_eigenschaften_trifft_nichts(db):
|
||
cat = _kategorie(db, "Trockenwaren")
|
||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("frisch gemahlen, optional", 1, "g")])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
|
||
|
||
def test_beiwoerter_stoeren_den_echten_treffer_nicht(db):
|
||
cat = _kategorie(db, "Obst & Gemüse")
|
||
aepfel = _artikel(db, "Äpfel", cat, bestand=3000)
|
||
|
||
[zutat] = pruefe_zutaten(
|
||
db, [_zutat("Apfel geschält und in 3 mm dünne Scheiben geschnitten", 900, "g")]
|
||
)
|
||
|
||
assert zutat.ziel_id == aepfel.id
|
||
|
||
|
||
def test_falsche_einheit_spricht_gegen_den_artikel(db):
|
||
"""„200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen"."""
|
||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||
_artikel(db, "Kräuter Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
|
||
|
||
assert zutat.status == "kein_treffer"
|
||
assert "nicht in 'ml' geführt" in zutat.hinweis
|
||
|
||
|
||
def test_passende_einheit_gewinnt_gegen_hoehere_punktzahl(db):
|
||
molk = _kategorie(db, "Molkereiprodukte")
|
||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||
_artikel(db, "Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
|
||
richtig = _artikel(db, "Schlagsahne Rahm", molk, base=BaseUnit.milliliter, bestand=500)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
|
||
|
||
assert zutat.ziel_id == richtig.id
|
||
|
||
|
||
def test_fehlendes_hausmass_spricht_nicht_gegen_den_artikel(db):
|
||
"""Nur eine BEKANNTE Einheit falscher Art ist ein Hinweis.
|
||
|
||
„1 Tasse Linsen" heisst nicht, dass die Linsen falsch sind – es fehlt bloss
|
||
die Maßangabe, und die laesst sich nachtragen.
|
||
"""
|
||
cat = _kategorie(db, "Hülsenfrüchte")
|
||
linsen = _artikel(db, "Berglinsen", cat, bestand=1000)
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
|
||
|
||
assert zutat.ziel_id == linsen.id
|
||
assert zutat.status == "menge_unbekannt"
|
||
|
||
|
||
def test_zweiteinheit_laesst_die_fremde_einheit_zu(db):
|
||
cat = _kategorie(db, "Backwaren")
|
||
broetchen = _artikel(db, "Brötchen", cat, base=BaseUnit.piece, package_size=6,
|
||
bestand=12)
|
||
broetchen.secondary_base = BaseUnit.gram.value
|
||
broetchen.secondary_count = 1
|
||
broetchen.secondary_amount = 50
|
||
db.commit()
|
||
|
||
[zutat] = pruefe_zutaten(db, [_zutat("Brötchen", 100, "g")])
|
||
|
||
assert zutat.ziel_id == broetchen.id
|
||
|
||
|
||
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
|