Files
ScarriffleandClaude Opus 5 8072e7f566 Zutaten von Hand zuordnen - und Vorrania merkt es sich
Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.

Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.

Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").

Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.

Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.

Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.

Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.

16 neue Tests, Suite bei 487.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:40:23 +02:00

306 lines
10 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.
"""Zutaten von Hand zuordnen – und dass sich Vorrania das merkt.
Der unscharfe Abgleich wird immer Zutaten haben, die er nicht erraten kann
(„Lime Curd") oder falsch errät. Hier liegt der Korrekturweg: einmal von Hand
entschieden, gilt es für jedes Rezept.
"""
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Group,
MealieFoodMap,
Product,
Role,
User,
)
from app.routers.recipes import (
list_zuordnungen,
loesche_zuordnung,
router,
setze_zuordnung,
zuordnung_ziele,
)
from app.schemas import ZuordnungIn
from app.services import zuordnung
from app.services.rezepte import pruefe_zutaten
from app.services.stock import check_in
@pytest.fixture()
def nutzer(db):
person = User(username="koch", 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, tracking=CategoryTracking.food):
cat = Category(name=name, tracking=tracking.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, bestand=0.0, **extra):
product = Product(name=name, base_unit=base, package_size=1000,
category_id=cat.id, source="manual", **extra)
db.add(product)
db.commit()
db.refresh(product)
if bestand:
check_in(db, product, bestand, base.value, None, None, None)
db.commit()
return product
# --------------------------------------------------------------------------
# Die Zuordnung greift
# --------------------------------------------------------------------------
def test_gemerkte_zuordnung_findet_was_das_raten_nicht_findet(db, nutzer):
"""„Lime Curd" hat mit „Zitronencreme" kein Wort gemeinsam."""
cat = _kategorie(db, "Aufstriche")
creme = _artikel(db, "Zitronencreme", cat, bestand=500)
[vorher] = pruefe_zutaten(db, [_zutat("Lime Curd", 100, "g")])
assert vorher.status == "kein_treffer"
setze_zuordnung(
ZuordnungIn(food="Lime Curd", art="product", ziel_id=creme.id),
db=db, _=nutzer,
)
[nachher] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert nachher.status == "vorhanden"
assert nachher.ziel_name == "Zitronencreme"
assert nachher.manuell is True
def test_zuordnung_schlaegt_einen_falschen_treffer(db, nutzer):
"""Der Kern der Beschwerde: Sahne ist kein Kräuter-Sahne-Hähnchen."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Kräuter Sahne Hähnchen", cat, bestand=2000)
sahne = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Sahne", art="product", ziel_id=sahne.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Sahne", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == sahne.id
assert zutat.status == "vorhanden"
def test_gross_und_kleinschreibung_treffen_dieselbe_zeile(db, nutzer):
cat = _kategorie(db, "Kuehlregal")
rahm = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Schlagsahne", art="product", ziel_id=rahm.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat(" SCHLAGSAHNE ", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == rahm.id
def test_zuordnung_auf_eine_gruppe(db, nutzer):
"""„Milch" ist markenübergreifend gemeint – dafür gibt es Gruppen."""
cat = _kategorie(db, "Kuehlregal")
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
_artikel(db, "H-Milch 3,5%", cat, base=BaseUnit.milliliter,
bestand=2000, group_id=gruppe.id)
setze_zuordnung(
ZuordnungIn(food="Vollmilch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Vollmilch", 500, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.art == "group"
assert zutat.ziel_id == gruppe.id
assert zutat.status == "vorhanden"
def test_wechsel_auf_eine_gruppe_laesst_den_artikel_nicht_stehen(db, nutzer):
"""Sonst stünden beide Ziele in der Zeile und eines gewänne zufällig."""
cat = _kategorie(db, "Kuehlregal")
milch = _artikel(db, "H-Milch", cat, base=BaseUnit.milliliter)
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
setze_zuordnung(ZuordnungIn(food="Milch", art="product", ziel_id=milch.id),
db=db, _=nutzer)
setze_zuordnung(ZuordnungIn(food="Milch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer)
zeile = db.get(MealieFoodMap, "milch")
assert zeile.product_id is None
assert zeile.group_id == gruppe.id
assert db.query(MealieFoodMap).count() == 1
# --------------------------------------------------------------------------
# Widerrufen und Aufräumen
# --------------------------------------------------------------------------
def test_loeschen_laesst_wieder_raten(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Reis", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
loesche_zuordnung(food="Reis", db=db, _=nutzer)
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Reis", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_loeschen_was_es_nicht_gibt_ist_kein_fehler(db, nutzer):
loesche_zuordnung(food="Gibtsnicht", db=db, _=nutzer)
def test_zuordnung_auf_einen_geloeschten_artikel_wird_uebersprungen(db, nutzer):
"""Auf SQLite greift kein Fremdschlüssel – das darf kein Rezept sprengen."""
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Lime Curd", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
db.delete(mehl)
db.commit()
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_unbekanntes_ziel_wird_abgelehnt(db, nutzer):
with pytest.raises(HTTPException) as fehler:
setze_zuordnung(ZuordnungIn(food="Mehl", art="product", ziel_id=999),
db=db, _=nutzer)
assert fehler.value.status_code == 400
assert db.query(MealieFoodMap).count() == 0
def test_zutat_ohne_namen_laesst_sich_nicht_merken(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
with pytest.raises(zuordnung.ZuordnungFehler):
zuordnung.setze(db, " ", "product", mehl.id)
# --------------------------------------------------------------------------
# Liste und Auswahl
# --------------------------------------------------------------------------
def test_liste_nennt_das_ziel_mit_namen(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
setze_zuordnung(ZuordnungIn(food="Flour", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
[eintrag] = list_zuordnungen(db=db, _=nutzer)
assert eintrag.food_name == "Flour"
assert eintrag.food_key == "flour"
assert eintrag.ziel_name == "Weissmehl"
def test_ziele_suchen_ueber_name_und_marke(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl Typ 550", cat, brand="Mühle Nord")
_artikel(db, "Zucker", cat)
namen = [z.name for z in zuordnung_ziele(q="mehl", db=db, _=nutzer)]
assert namen == ["Weissmehl Typ 550"]
nach_marke = [z.name for z in zuordnung_ziele(q="mühle nord", db=db, _=nutzer)]
assert nach_marke == ["Weissmehl Typ 550"]
def test_ziele_nennen_die_basiseinheit(db, nutzer):
"""Damit man vor dem Zuordnen sieht, ob ml oder g geführt wird."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter)
[ziel] = zuordnung_ziele(q="schlagrahm", db=db, _=nutzer)
assert ziel.basis_einheit == "milliliter"
def test_ziele_enthalten_gegenstaende_aber_keine_einzelstuecke(db, nutzer):
"""Raten darf nur Lebensmittel; von Hand zuordnen darf alles Zählbare.
Backpapier ist kein Lebensmittel und steht trotzdem in Rezepten. Ein
Einzelstück dagegen ist ein benanntes Objekt und nie eine Zutat.
"""
sachen = _kategorie(db, "Haushalt", tracking=CategoryTracking.object)
_artikel(db, "Backpapier", sachen, base=BaseUnit.piece)
_artikel(db, "Backpapier-Spender", sachen, base=BaseUnit.piece, individual=True)
namen = [z.name for z in zuordnung_ziele(q="backpapier", db=db, _=nutzer)]
assert namen == ["Backpapier"]
def test_gruppen_stehen_vor_den_artikeln(db, nutzer):
"""Ohne Suchtext zuerst die Gruppen – sie sind die bessere Zuordnung."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Alpenmilch", cat, base=BaseUnit.milliliter)
db.add(Group(name="Trinkmilch"))
db.commit()
arten = [z.art for z in zuordnung_ziele(db=db, _=nutzer)]
assert arten[0] == "group"
# --------------------------------------------------------------------------
# Routenreihenfolge
# --------------------------------------------------------------------------
def test_zuordnungs_routen_stehen_vor_dem_slug():
"""Sonst liest FastAPI „zuordnungen" als Rezept-Slug und fragt Mealie.
Die Reihenfolge der Deklaration entscheidet das – ein Umsortieren der
Datei würde den Fehler sonst still einbauen.
"""
pfade = [r.path for r in router.routes]
slug = pfade.index("/recipes/{slug}")
assert pfade.index("/recipes/zuordnungen") < slug
assert pfade.index("/recipes/zuordnungen/ziele") < slug