Fix three bugs found by running against the live instance
suggest_recipes returned 422 for every call. The endpoint takes food UUIDs as
repeated query parameters, not a comma-joined string of names — the rule
inherited from the skill was never actually verified. Added resolve_foods to
map names to food records first, and report anything unresolved instead of
dropping it.
scale_ingredients silently returned unscaled lines. It read the structured
quantity field and string-replaced str(quantity) into the display text, which
failed twice over: unparsed recipes store quantity 0.0 with the real amount only
in the text, and str(800.0) never matches "800 g kycklinglårfilé". Amounts are
now read from the display text, ranges scale at both ends ("1-1,5 msk"), mixed
numbers scale as one value ("1 1/2 msk"), and lines with no amount are reported
in `unscaled` rather than presented as if they had been scaled.
looks_mangled flagged the readable "1 1/2 msk tomatpuré" because its duplicate-word
rule matched the repeated digit. Restricted to alphabetic words, so it still
catches "2 dl dl grädde".
Verified against all 238 recipes in the live library: no false positives remain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+122
-2
@@ -1,5 +1,125 @@
|
||||
"""Scaling must never present an unscaled line as scaled.
|
||||
|
||||
The original implementation read the structured `quantity` field and string-replaced
|
||||
`str(quantity)` into the display text. Against the live library that silently did
|
||||
nothing twice over: unparsed recipes store `quantity: 0.0` with the real amount only
|
||||
in the text, and `str(800.0)` == "800.0" never matches "800 g kycklinglårfilé".
|
||||
The result was a confident wrong answer — the worst kind for a shopping list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from mealie_mcp.normalize import format_amount, scale_line
|
||||
from mealie_mcp.server import scale_ingredients
|
||||
|
||||
|
||||
def test_module_imports():
|
||||
assert callable(scale_ingredients)
|
||||
class TestFormatAmount:
|
||||
def test_whole_numbers_lose_the_decimal(self):
|
||||
assert format_amount(Decimal("1200")) == "1200"
|
||||
|
||||
def test_fractions_use_the_swedish_decimal_comma(self):
|
||||
assert format_amount(Decimal("1.5")) == "1,5"
|
||||
|
||||
def test_rounds_to_one_decimal(self):
|
||||
assert format_amount(Decimal("0.66")) == "0,7"
|
||||
|
||||
|
||||
class TestScaleLine:
|
||||
def test_scales_a_leading_gram_amount(self):
|
||||
assert scale_line("800 g kycklinglårfilé", Decimal("1.5")) == ("1200 g kycklinglårfilé", True)
|
||||
|
||||
def test_scales_a_swedish_decimal_amount(self):
|
||||
assert scale_line("4,7 dl basmatiris", Decimal("2")) == ("9,4 dl basmatiris", True)
|
||||
|
||||
def test_scales_a_discrete_item(self):
|
||||
assert scale_line("1 klyfta vitlök", Decimal("3")) == ("3 klyfta vitlök", True)
|
||||
|
||||
def test_line_without_an_amount_is_reported_as_unscaled(self):
|
||||
assert scale_line("salt", Decimal("2")) == ("salt", False)
|
||||
|
||||
def test_parenthetical_line_without_amount_is_untouched(self):
|
||||
assert scale_line("smör (att steka i)", Decimal("2")) == ("smör (att steka i)", False)
|
||||
|
||||
def test_does_not_touch_a_number_that_is_not_leading(self):
|
||||
# "Cheddar 40%" must not become "Cheddar 80%".
|
||||
assert scale_line("cheddar 40% fetthalt", Decimal("2")) == ("cheddar 40% fetthalt", False)
|
||||
|
||||
|
||||
class TestScaleIngredients:
|
||||
def _recipe(self, **overrides):
|
||||
recipe = {
|
||||
"name": "Provencalsk kycklinggryta",
|
||||
"recipeYield": "",
|
||||
"recipeServings": 4.0,
|
||||
"recipeYieldQuantity": 0.0,
|
||||
"recipeIngredient": [
|
||||
# Verified live shape: unparsed rows carry quantity 0.0.
|
||||
{"display": "800 g kycklinglårfilé", "quantity": 0.0},
|
||||
{"display": "salt", "quantity": 0.0},
|
||||
{"display": "1 klyfta vitlök", "quantity": 0.0},
|
||||
],
|
||||
}
|
||||
recipe.update(overrides)
|
||||
return recipe
|
||||
|
||||
def test_scales_from_recipe_servings_when_yield_is_empty(self, monkeypatch):
|
||||
monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: self._recipe())
|
||||
result = scale_ingredients("x", 6)
|
||||
assert result["factor"] == 1.5
|
||||
assert result["ingredients"][0] == "1200 g kycklinglårfilé"
|
||||
assert result["ingredients"][2] == "1,5 klyfta vitlök"
|
||||
|
||||
def test_lines_without_amounts_are_listed_not_hidden(self, monkeypatch):
|
||||
monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: self._recipe())
|
||||
assert scale_ingredients("x", 6)["unscaled"] == ["salt"]
|
||||
|
||||
def test_falls_back_to_a_numeric_leading_recipe_yield(self, monkeypatch):
|
||||
recipe = self._recipe(recipeServings=0.0, recipeYield="4 portioner")
|
||||
monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: recipe)
|
||||
assert scale_ingredients("x", 8)["factor"] == 2.0
|
||||
|
||||
def test_refuses_to_guess_without_serving_metadata(self, monkeypatch):
|
||||
recipe = self._recipe(recipeServings=0.0, recipeYield="", recipeYieldQuantity=0.0)
|
||||
monkeypatch.setattr("mealie_mcp.server.get_recipe", lambda _s: recipe)
|
||||
with pytest.raises(ValueError, match="serving metadata"):
|
||||
scale_ingredients("x", 6)
|
||||
|
||||
|
||||
class TestRanges:
|
||||
"""Live data: 'Provencalsk kycklinggryta' stores '1-1,5 msk koncentrerad kycklingfond'."""
|
||||
|
||||
def test_both_ends_of_a_range_are_scaled(self):
|
||||
assert scale_line("1-1,5 msk koncentrerad kycklingfond", Decimal("1.5")) == (
|
||||
"1,5-2,3 msk koncentrerad kycklingfond",
|
||||
True,
|
||||
)
|
||||
|
||||
def test_integer_range_stays_readable(self):
|
||||
assert scale_line("2-3 dl vatten", Decimal("2")) == ("4-6 dl vatten", True)
|
||||
|
||||
def test_en_dash_range_is_handled(self):
|
||||
assert scale_line("1–2 st lök", Decimal("2")) == ("2-4 st lök", True)
|
||||
|
||||
|
||||
class TestMixedFractions:
|
||||
"""Live data: 'tortillakebab-pa-grillen' stores '1 1/2 msk tomatpuré'."""
|
||||
|
||||
def test_mixed_number_scales_as_one_value(self):
|
||||
# Naively scaling the leading "1" would yield "2 1/2 msk" instead of "3".
|
||||
assert scale_line("1 1/2 msk tomatpuré", Decimal("2")) == ("3 msk tomatpuré", True)
|
||||
|
||||
def test_bare_fraction_scales(self):
|
||||
assert scale_line("1/2 tsk salt", Decimal("3")) == ("1,5 tsk salt", True)
|
||||
|
||||
def test_mixed_number_is_readable_not_mangled(self):
|
||||
from mealie_mcp.normalize import looks_mangled
|
||||
|
||||
assert not looks_mangled("1 1/2 msk tomatpuré")
|
||||
|
||||
def test_duplicated_unit_is_still_mangled(self):
|
||||
from mealie_mcp.normalize import looks_mangled
|
||||
|
||||
assert looks_mangled("2 dl dl grädde")
|
||||
|
||||
Reference in New Issue
Block a user