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:
@@ -151,6 +151,86 @@ def scale_line(line: str, factor: Decimal) -> tuple[str, bool]:
|
|||||||
return line[: match.start("low")] + replacement + line[match.end() :], True
|
return line[: match.start("low")] + replacement + line[match.end() :], True
|
||||||
|
|
||||||
|
|
||||||
|
#: The word directly after a leading amount — the unit candidate in a Swedish line.
|
||||||
|
_UNIT_TOKEN = re.compile(
|
||||||
|
rf"^\s*(?:{_AMOUNT})(?:\s*[-–]\s*(?:{_AMOUNT}))?\s+(?P<token>[^\W\d_]+)",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unit_token(line: str) -> str | None:
|
||||||
|
"""Return the word following the leading amount, or None if the line has no amount.
|
||||||
|
|
||||||
|
This is the unit as *written* (``dl``, ``msk``, ``g``, ``klyfta``). It is the
|
||||||
|
authority for which unit a row means, because the Mealie NLP parser resolves
|
||||||
|
``3 dl`` to the ``liter`` record — a verified 10x error on a live instance.
|
||||||
|
Not every match is a unit (``0,5 citron`` yields ``citron``); callers confirm
|
||||||
|
the token against the instance's own unit table.
|
||||||
|
"""
|
||||||
|
match = _UNIT_TOKEN.match(line)
|
||||||
|
return match.group("token") if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def match_unit(token: str | None, units: list[dict]) -> dict | None:
|
||||||
|
"""Find the unit record a written token refers to, by name or abbreviation.
|
||||||
|
|
||||||
|
Matches singular and plural forms of both. Returns None when the token is not
|
||||||
|
a unit in this Mealie instance, which is the signal to store no unit at all
|
||||||
|
rather than the parser's guess.
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
needle = token.strip().lower()
|
||||||
|
fields = ("name", "pluralName", "abbreviation", "pluralAbbreviation")
|
||||||
|
for unit in units:
|
||||||
|
if any((unit.get(field) or "").strip().lower() == needle for field in fields):
|
||||||
|
return unit
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def food_matches_line(food_name: str, line: str) -> bool:
|
||||||
|
"""True when the parser's food is actually the ingredient the line names.
|
||||||
|
|
||||||
|
The parser substitutes near neighbours from the existing food table —
|
||||||
|
``800 g kycklinglårfilé`` came back as the food ``kycklingfilé``. A food is
|
||||||
|
accepted only when its name appears in the line outright (``koncentrerad
|
||||||
|
kycklingfond``) or begins a word in it, which keeps the singular record
|
||||||
|
``körsbärstomat`` for ``körsbärstomater`` while rejecting the lår/filé swap.
|
||||||
|
"""
|
||||||
|
name = (food_name or "").strip().lower()
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
# The name must begin a word, so `lök` does not match inside `vitlök`, while a
|
||||||
|
# trailing plural (`körsbärstomat` in `körsbärstomater`) is still allowed.
|
||||||
|
return re.search(rf"(?<![^\W\d_]){re.escape(name)}", line.lower()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def line_remainder(line: str, written_unit: str | None, food_name: str | None) -> str:
|
||||||
|
"""Return what is left of a line once amount, unit, and food are held as structure.
|
||||||
|
|
||||||
|
Mealie composes the visible row as ``quantity unit food note``, so ``note`` must
|
||||||
|
hold only the leftover text. Passing the whole line duplicates it
|
||||||
|
(``3 dl vispgrädde 3 dl vispgrädde``). The food match is widened to the end of
|
||||||
|
the word it starts, so the record ``körsbärstomat`` consumes
|
||||||
|
``körsbärstomater`` and leaves ``(i olika färger)``.
|
||||||
|
"""
|
||||||
|
rest = _LEADING_AMOUNT.sub("", line, count=1)
|
||||||
|
if written_unit:
|
||||||
|
rest = re.sub(rf"^\s*{re.escape(written_unit)}\b", "", rest, count=1, flags=re.IGNORECASE)
|
||||||
|
if food_name:
|
||||||
|
match = re.search(
|
||||||
|
rf"(?<![^\W\d_]){re.escape(food_name.strip().lower())}[^\W\d_]*", rest.lower()
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
rest = rest[: match.start()] + rest[match.end() :]
|
||||||
|
return _WHITESPACE.sub(" ", rest).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def same_line(left: str, right: str) -> bool:
|
||||||
|
"""Compare two ingredient rows ignoring only whitespace differences."""
|
||||||
|
return _WHITESPACE.sub(" ", left or "").strip() == _WHITESPACE.sub(" ", right or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def looks_mangled(line: str) -> bool:
|
def looks_mangled(line: str) -> bool:
|
||||||
"""Heuristic for parser-damaged display text.
|
"""Heuristic for parser-damaged display text.
|
||||||
|
|
||||||
|
|||||||
+150
-30
@@ -13,11 +13,16 @@ from mcp.server.mcpserver import MCPServer
|
|||||||
|
|
||||||
from .normalize import (
|
from .normalize import (
|
||||||
clean_title,
|
clean_title,
|
||||||
|
food_matches_line,
|
||||||
has_extraction_failure,
|
has_extraction_failure,
|
||||||
ingredient_display_lines,
|
ingredient_display_lines,
|
||||||
|
line_remainder,
|
||||||
looks_mangled,
|
looks_mangled,
|
||||||
|
match_unit,
|
||||||
normalize_for_parser,
|
normalize_for_parser,
|
||||||
|
same_line,
|
||||||
scale_line,
|
scale_line,
|
||||||
|
unit_token,
|
||||||
)
|
)
|
||||||
from .verify import verify_recipe as run_verification
|
from .verify import verify_recipe as run_verification
|
||||||
|
|
||||||
@@ -319,14 +324,95 @@ def patch_recipe(slug_or_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return get_recipe(slug_or_id)
|
return get_recipe(slug_or_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_records() -> list[dict[str, Any]]:
|
||||||
|
"""The instance's own unit table — the authority for what `dl` and `msk` mean."""
|
||||||
|
data = _request("GET", "/api/units", params={"perPage": 200})
|
||||||
|
return data.get("items", data if isinstance(data, list) else [])
|
||||||
|
|
||||||
|
|
||||||
|
def _vet_structure(line: str, ingredient: dict[str, Any], units: list[dict[str, Any]]) -> tuple[
|
||||||
|
Any, dict[str, Any] | None, dict[str, Any] | None, list[str]
|
||||||
|
]:
|
||||||
|
"""Keep only parser output the Swedish line actually supports.
|
||||||
|
|
||||||
|
Verified against the live instance: the parser resolves `3 dl` to the `liter`
|
||||||
|
record, swaps `kycklinglårfilé` for the existing food `kycklingfilé`, and
|
||||||
|
invents foods with ``id: null`` — the last of which makes the recipe PATCH
|
||||||
|
fail with a 500. Unsupported values are dropped, never guessed at, and every
|
||||||
|
drop is reported so the caller can see what was not structured.
|
||||||
|
"""
|
||||||
|
quantity = ingredient.get("quantity")
|
||||||
|
unit = ingredient.get("unit")
|
||||||
|
food = ingredient.get("food")
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
if looks_mangled(line):
|
||||||
|
return None, None, None, ["stored line is already parser-damaged; left unstructured"]
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
written = unit_token(line)
|
||||||
|
confirmed = match_unit(written, units)
|
||||||
|
if confirmed and confirmed.get("id") != unit.get("id"):
|
||||||
|
notes.append(
|
||||||
|
f"unit {unit.get('name')!r} corrected to {confirmed.get('name')!r} "
|
||||||
|
f"from the written token {written!r}"
|
||||||
|
)
|
||||||
|
unit = confirmed
|
||||||
|
elif not confirmed:
|
||||||
|
notes.append(
|
||||||
|
f"unit {unit.get('name')!r} is not what the line writes "
|
||||||
|
f"({written!r}); dropped"
|
||||||
|
)
|
||||||
|
unit = None
|
||||||
|
|
||||||
|
if food and not food.get("id"):
|
||||||
|
# A food with no id makes Mealie 500 on the recipe patch (ValueError).
|
||||||
|
notes.append(f"food {food.get('name')!r} does not exist in Mealie; dropped")
|
||||||
|
food = None
|
||||||
|
elif food and not food_matches_line(food.get("name") or "", line):
|
||||||
|
notes.append(f"food {food.get('name')!r} is not the ingredient this line names; dropped")
|
||||||
|
food = None
|
||||||
|
|
||||||
|
return quantity, unit, food, notes
|
||||||
|
|
||||||
|
|
||||||
|
def _unstructured_item(base: dict[str, Any], line: str) -> dict[str, Any]:
|
||||||
|
"""A row carrying only the human line — Mealie renders ``note`` verbatim."""
|
||||||
|
item = dict(base)
|
||||||
|
item["quantity"] = 0
|
||||||
|
item["unit"] = None
|
||||||
|
item["food"] = None
|
||||||
|
item["note"] = line
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_item(
|
||||||
|
base: dict[str, Any], line: str, quantity: Any, unit: Any, food: Any
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A row holding structure, with ``note`` reduced to the leftover text."""
|
||||||
|
item = dict(base)
|
||||||
|
item["quantity"] = quantity
|
||||||
|
item["unit"] = unit
|
||||||
|
item["food"] = food
|
||||||
|
item["note"] = line_remainder(line, unit_token(line) if unit else None,
|
||||||
|
(food or {}).get("name") if food else None)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
|
def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
|
||||||
"""Add structured food/unit/quantity data while keeping the Swedish display text.
|
"""Add structured food/unit/quantity data without changing any visible Swedish line.
|
||||||
|
|
||||||
The parser is a structured-data helper only. Its ``display`` output mangles
|
``display`` is computed by Mealie from ``quantity unit food note`` and cannot be
|
||||||
Swedish rows (``4,7 dl basmatiris`` came back as ``47⁄10 liter ...``), so the
|
set through the API — writing it is silently ignored. Structuring a row
|
||||||
canonical human line is preserved and restored after parsing. Rows the parser
|
therefore rewrites how it reads, and Mealie's rendering is not always the
|
||||||
still mangles keep readable text with structure cleared — legibility wins.
|
Swedish the recipe was written in (``800 g`` renders as ``800 gram``, ``0,25``
|
||||||
|
as ``¹/₄``, and plural foods render singular).
|
||||||
|
|
||||||
|
So structure is applied, read back, and kept only for rows Mealie renders
|
||||||
|
identically to the original line. Any row it would have reworded is restored
|
||||||
|
unstructured, and reported in ``left_unstructured`` with the text Mealie wanted
|
||||||
|
to show. The visible line is never changed by this tool.
|
||||||
"""
|
"""
|
||||||
recipe = get_recipe(slug_or_id)
|
recipe = get_recipe(slug_or_id)
|
||||||
canonical = ingredient_display_lines(recipe)
|
canonical = ingredient_display_lines(recipe)
|
||||||
@@ -338,43 +424,77 @@ def parse_ingredients(slug_or_id: str) -> dict[str, Any]:
|
|||||||
"/api/parser/ingredients",
|
"/api/parser/ingredients",
|
||||||
json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"},
|
json={"ingredients": [normalize_for_parser(line) for line in canonical], "parser": "nlp"},
|
||||||
)
|
)
|
||||||
|
units = _unit_records()
|
||||||
|
|
||||||
ingredients = list(recipe.get("recipeIngredient") or [])
|
base = list(recipe.get("recipeIngredient") or [])
|
||||||
degraded: list[str] = []
|
candidates = [_unstructured_item(item, line) for item, line in zip(base, canonical)]
|
||||||
|
corrections: list[dict[str, Any]] = []
|
||||||
for index, result in enumerate(parsed or []):
|
for index, result in enumerate(parsed or []):
|
||||||
if index >= len(ingredients):
|
if index >= len(base):
|
||||||
break
|
break
|
||||||
item = dict(ingredients[index])
|
line = canonical[index]
|
||||||
confidence = (result or {}).get("ingredient") or {}
|
quantity, unit, food, notes = _vet_structure(
|
||||||
item["quantity"] = confidence.get("quantity")
|
line, (result or {}).get("ingredient") or {}, units
|
||||||
item["unit"] = confidence.get("unit")
|
)
|
||||||
item["food"] = confidence.get("food")
|
if notes:
|
||||||
# The canonical Swedish line always wins over parser-generated display text.
|
corrections.append({"line": line, "notes": notes})
|
||||||
item["display"] = canonical[index]
|
if unit or food:
|
||||||
item["note"] = canonical[index]
|
candidates[index] = _structured_item(base[index], line, quantity, unit, food)
|
||||||
if looks_mangled(canonical[index]):
|
|
||||||
item["quantity"] = None
|
|
||||||
item["unit"] = None
|
|
||||||
item["food"] = None
|
|
||||||
degraded.append(canonical[index])
|
|
||||||
ingredients[index] = item
|
|
||||||
|
|
||||||
try:
|
rejected: list[int] = []
|
||||||
_request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": ingredients})
|
|
||||||
except httpx.HTTPStatusError as exc:
|
def apply(items: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||||
# A full recipeIngredient patch with nested parser objects can 500 here.
|
try:
|
||||||
# Preserve the readable import rather than corrupting the recipe.
|
_request("PATCH", f"/api/recipes/{slug_or_id}", json={"recipeIngredient": items})
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
rejected.append(exc.response.status_code)
|
||||||
|
return None
|
||||||
|
return get_recipe(slug_or_id).get("recipeIngredient") or []
|
||||||
|
|
||||||
|
stored = apply(candidates)
|
||||||
|
if stored is None:
|
||||||
|
# A rejected patch writes nothing, so the readable import is already intact.
|
||||||
return {
|
return {
|
||||||
"parsed": False,
|
"parsed": False,
|
||||||
"reason": f"Structured patch rejected by Mealie ({exc.response.status_code}); "
|
"reason": f"Structured patch rejected by Mealie ({rejected[0]}); the recipe "
|
||||||
"readable Swedish lines were left intact",
|
"was not modified",
|
||||||
"canonical_lines": canonical,
|
"canonical_lines": canonical,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mealie is the only authority on how a row renders, so compare what it stored.
|
||||||
|
reverted: list[dict[str, Any]] = []
|
||||||
|
final = list(candidates)
|
||||||
|
for index, line in enumerate(canonical):
|
||||||
|
if index >= len(stored):
|
||||||
|
break
|
||||||
|
shown = stored[index].get("display") or ""
|
||||||
|
if not same_line(shown, line):
|
||||||
|
final[index] = _unstructured_item(base[index], line)
|
||||||
|
reverted.append({"line": line, "mealie_would_show": shown})
|
||||||
|
|
||||||
|
if reverted:
|
||||||
|
# The first patch succeeded, so these rows are stored reworded right now and
|
||||||
|
# the revert is a repair, not a precaution — say so plainly if it fails.
|
||||||
|
stored = apply(final)
|
||||||
|
if stored is None:
|
||||||
|
return {
|
||||||
|
"parsed": False,
|
||||||
|
"reason": f"Rows were reworded by Mealie and the repair patch failed "
|
||||||
|
f"({rejected[-1]}); these lines are still reworded in Mealie "
|
||||||
|
f"and need fixing by hand",
|
||||||
|
"reworded_rows": reverted,
|
||||||
|
}
|
||||||
|
|
||||||
|
structured = [line for index, line in enumerate(canonical)
|
||||||
|
if final[index].get("unit") or final[index].get("food")]
|
||||||
return {
|
return {
|
||||||
"parsed": True,
|
"parsed": True,
|
||||||
|
"structured_rows": structured,
|
||||||
|
"left_unstructured": reverted,
|
||||||
|
"corrections": corrections,
|
||||||
|
"lines_unchanged": all(same_line((stored[i].get("display") or ""), line)
|
||||||
|
for i, line in enumerate(canonical) if i < len(stored)),
|
||||||
"recipe": get_recipe(slug_or_id),
|
"recipe": get_recipe(slug_or_id),
|
||||||
"readability_fallback_rows": degraded,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ import pytest
|
|||||||
from mealie_mcp.normalize import (
|
from mealie_mcp.normalize import (
|
||||||
clean_title,
|
clean_title,
|
||||||
find_english_leftovers,
|
find_english_leftovers,
|
||||||
|
food_matches_line,
|
||||||
has_extraction_failure,
|
has_extraction_failure,
|
||||||
ingredient_display_lines,
|
ingredient_display_lines,
|
||||||
|
line_remainder,
|
||||||
looks_mangled,
|
looks_mangled,
|
||||||
|
match_unit,
|
||||||
normalize_for_parser,
|
normalize_for_parser,
|
||||||
|
same_line,
|
||||||
slugify,
|
slugify,
|
||||||
|
unit_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,3 +102,33 @@ class TestIngredientDisplayLines:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"]
|
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:
|
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"
|
canonical = "4,7 dl basmatiris"
|
||||||
recipe = {
|
recipe = {
|
||||||
"slug": "ris",
|
"slug": "ris",
|
||||||
"recipeIngredient": [{"display": canonical, "note": canonical}],
|
"recipeIngredient": [{"display": canonical, "note": canonical}],
|
||||||
}
|
}
|
||||||
seen: dict[str, object] = {}
|
patches: list[list[dict]] = []
|
||||||
|
reads = 0
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
|
nonlocal reads
|
||||||
if request.url.path == "/api/parser/ingredients":
|
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=[{
|
return httpx.Response(200, json=[{
|
||||||
"ingredient": {
|
"ingredient": {
|
||||||
"quantity": 4.7,
|
"quantity": 4.7,
|
||||||
"unit": {"id": "u", "name": "dl"},
|
"unit": {"id": "liter", "name": "liter"},
|
||||||
"food": {"id": "f", "name": "basmatiris"},
|
"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":
|
if request.method == "PATCH":
|
||||||
seen["patched"] = _json.loads(request.content)["recipeIngredient"]
|
patches.append(_json.loads(request.content)["recipeIngredient"])
|
||||||
return httpx.Response(200, json={})
|
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)
|
return httpx.Response(200, json=recipe)
|
||||||
|
|
||||||
mock_mealie(handler)
|
mock_mealie(handler)
|
||||||
server.parse_ingredients("ris")
|
result = server.parse_ingredients("ris")
|
||||||
|
|
||||||
# Decimal comma is normalized for the parser only...
|
assert len(patches) == 2
|
||||||
assert seen["sent"] == ["4.7 dl basmatiris"]
|
assert patches[0][0]["unit"]["id"] == "dl"
|
||||||
patched = seen["patched"][0]
|
assert patches[1][0]["quantity"] == 0
|
||||||
# ...while the stored human-facing line stays the original Swedish text.
|
assert patches[1][0]["unit"] is None
|
||||||
assert patched["display"] == canonical
|
assert patches[1][0]["food"] is None
|
||||||
assert patched["note"] == canonical
|
assert patches[1][0]["note"] == canonical
|
||||||
# Structured data from the parser is still applied.
|
assert result["lines_unchanged"] is True
|
||||||
assert patched["quantity"] == 4.7
|
assert result["left_unstructured"] == [{
|
||||||
assert patched["food"]["name"] == "basmatiris"
|
"line": canonical,
|
||||||
|
"mealie_would_show": "47⁄10 dl basmatiris",
|
||||||
|
}]
|
||||||
|
|
||||||
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
|
def test_failed_structured_patch_preserves_the_readable_import(self, mock_mealie):
|
||||||
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}
|
recipe = {"slug": "ris", "recipeIngredient": [{"display": "2 dl grädde"}]}
|
||||||
|
|||||||
Reference in New Issue
Block a user