Make ingredient parsing safe against live Mealie rewrites
Co-authored-by: Fizz <fizz@agents.famfallman.com> Co-authored-by: fredamn76 <fredrik.fallman@gmail.com> Signed-off-by: fredamn76 <fredrik.fallman@gmail.com>
This commit is contained in:
@@ -6,11 +6,16 @@ import pytest
|
||||
from mealie_mcp.normalize import (
|
||||
clean_title,
|
||||
find_english_leftovers,
|
||||
food_matches_line,
|
||||
has_extraction_failure,
|
||||
ingredient_display_lines,
|
||||
line_remainder,
|
||||
looks_mangled,
|
||||
match_unit,
|
||||
normalize_for_parser,
|
||||
same_line,
|
||||
slugify,
|
||||
unit_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,3 +102,33 @@ class TestIngredientDisplayLines:
|
||||
]
|
||||
}
|
||||
assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"]
|
||||
|
||||
|
||||
class TestParserStructureSafety:
|
||||
def test_reads_the_written_unit_token(self):
|
||||
assert unit_token("4,7 dl basmatiris") == "dl"
|
||||
assert unit_token("1-1,5 msk fond") == "msk"
|
||||
assert unit_token("salt") is None
|
||||
|
||||
def test_matches_instance_unit_by_abbreviation(self):
|
||||
gram = {"id": "g", "name": "gram", "abbreviation": "g"}
|
||||
assert match_unit("g", [gram]) == gram
|
||||
assert match_unit("dl", [gram]) is None
|
||||
|
||||
def test_rejects_a_nearby_but_different_food(self):
|
||||
assert food_matches_line("kycklinglårfilé", "800 g kycklinglårfilé")
|
||||
assert not food_matches_line("kycklingfilé", "800 g kycklinglårfilé")
|
||||
|
||||
def test_builds_note_from_only_the_unstructured_remainder(self):
|
||||
assert (
|
||||
line_remainder(
|
||||
"200 g körsbärstomater (i olika färger)",
|
||||
"g",
|
||||
"körsbärstomat",
|
||||
)
|
||||
== "(i olika färger)"
|
||||
)
|
||||
|
||||
def test_same_line_ignores_only_whitespace(self):
|
||||
assert same_line("3 dl grädde", " 3 dl grädde ")
|
||||
assert not same_line("3 dl grädde", "3 liter grädde")
|
||||
|
||||
+35
-17
@@ -97,45 +97,63 @@ class TestImportReport:
|
||||
|
||||
|
||||
class TestParseIngredients:
|
||||
def test_swedish_display_text_survives_the_parser(self, mock_mealie):
|
||||
def test_reverts_structure_when_mealie_rewords_the_swedish_line(self, mock_mealie):
|
||||
canonical = "4,7 dl basmatiris"
|
||||
recipe = {
|
||||
"slug": "ris",
|
||||
"recipeIngredient": [{"display": canonical, "note": canonical}],
|
||||
}
|
||||
seen: dict[str, object] = {}
|
||||
patches: list[list[dict]] = []
|
||||
reads = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
nonlocal reads
|
||||
if request.url.path == "/api/parser/ingredients":
|
||||
seen["sent"] = _json.loads(request.content)["ingredients"]
|
||||
# The parser returns mangled display text; it must not be stored.
|
||||
return httpx.Response(200, json=[{
|
||||
"ingredient": {
|
||||
"quantity": 4.7,
|
||||
"unit": {"id": "u", "name": "dl"},
|
||||
"unit": {"id": "liter", "name": "liter"},
|
||||
"food": {"id": "f", "name": "basmatiris"},
|
||||
"display": "47⁄10 liter basmatiris",
|
||||
}
|
||||
}])
|
||||
if request.url.path == "/api/units":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"items": [{"id": "dl", "name": "dl", "abbreviation": "dl"}]},
|
||||
)
|
||||
if request.method == "PATCH":
|
||||
seen["patched"] = _json.loads(request.content)["recipeIngredient"]
|
||||
patches.append(_json.loads(request.content)["recipeIngredient"])
|
||||
return httpx.Response(200, json={})
|
||||
reads += 1
|
||||
if reads == 1:
|
||||
return httpx.Response(200, json=recipe)
|
||||
if len(patches) == 1:
|
||||
# This is what Mealie rendered from the first structured patch.
|
||||
return httpx.Response(200, json={
|
||||
"slug": "ris",
|
||||
"recipeIngredient": [{
|
||||
**patches[0][0],
|
||||
"display": "47⁄10 dl basmatiris",
|
||||
}],
|
||||
})
|
||||
return httpx.Response(200, json=recipe)
|
||||
|
||||
mock_mealie(handler)
|
||||
server.parse_ingredients("ris")
|
||||
result = server.parse_ingredients("ris")
|
||||
|
||||
# Decimal comma is normalized for the parser only...
|
||||
assert seen["sent"] == ["4.7 dl basmatiris"]
|
||||
patched = seen["patched"][0]
|
||||
# ...while the stored human-facing line stays the original Swedish text.
|
||||
assert patched["display"] == canonical
|
||||
assert patched["note"] == canonical
|
||||
# Structured data from the parser is still applied.
|
||||
assert patched["quantity"] == 4.7
|
||||
assert patched["food"]["name"] == "basmatiris"
|
||||
assert len(patches) == 2
|
||||
assert patches[0][0]["unit"]["id"] == "dl"
|
||||
assert patches[1][0]["quantity"] == 0
|
||||
assert patches[1][0]["unit"] is None
|
||||
assert patches[1][0]["food"] is None
|
||||
assert patches[1][0]["note"] == canonical
|
||||
assert result["lines_unchanged"] is True
|
||||
assert result["left_unstructured"] == [{
|
||||
"line": canonical,
|
||||
"mealie_would_show": "47⁄10 dl basmatiris",
|
||||
}]
|
||||
|
||||
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
|
||||
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}
|
||||
|
||||
Reference in New Issue
Block a user