f2d233b430
Ports a working Hermes agent skill to an MCP server. The skill's value was not its Mealie endpoints but ~25 operational rules found by running imports against the live instance; those are now code with tests rather than prompt text. The server owns deterministic mechanics — auth, the non-default User-Agent Cloudflare requires, the `extension` field on cover uploads, PATCH instead of the 500-ing bulk-action endpoints, decimal-comma normalization, and restoring Swedish display text after parsing. Language and taste judgement stay with the model, fed by the four reference notes shipped as MCP resources. verify_recipe runs the finished-import definition as code so an agent cannot report success on a recipe with an English ingredient line, a missing cover, or ingredients still in the "Click Parse" state. Home Assistant is optional and isolated; the Obsidian meal plan is out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
3.3 KiB
Python
100 lines
3.3 KiB
Python
"""Cases taken from failures verified against the live Mealie instance."""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from mealie_mcp.normalize import (
|
||
clean_title,
|
||
find_english_leftovers,
|
||
has_extraction_failure,
|
||
ingredient_display_lines,
|
||
looks_mangled,
|
||
normalize_for_parser,
|
||
slugify,
|
||
)
|
||
|
||
|
||
class TestNormalizeForParser:
|
||
def test_converts_swedish_decimal_comma(self):
|
||
assert normalize_for_parser("4,7 dl basmatiris") == "4.7 dl basmatiris"
|
||
|
||
def test_leaves_list_commas_alone(self):
|
||
assert normalize_for_parser("salt, peppar") == "salt, peppar"
|
||
|
||
def test_collapses_whitespace(self):
|
||
assert normalize_for_parser(" 2 msk olja ") == "2 msk olja"
|
||
|
||
def test_is_idempotent(self):
|
||
once = normalize_for_parser("0,6 dl grädde")
|
||
assert normalize_for_parser(once) == once
|
||
|
||
|
||
class TestCleanTitle:
|
||
@pytest.mark.parametrize(
|
||
"raw,expected",
|
||
[
|
||
("Fläskfilé med svampsås - se & gör", "Fläskfilé med svampsås"),
|
||
("Kycklinggryta | Köket.se", "Kycklinggryta"),
|
||
("Pannkakor - se och gör", "Pannkakor"),
|
||
("Lax i ugn", "Lax i ugn"),
|
||
],
|
||
)
|
||
def test_strips_campaign_suffixes(self, raw, expected):
|
||
assert clean_title(raw) == expected
|
||
|
||
def test_strips_stacked_suffixes(self):
|
||
assert clean_title("Ugnslax - se & gör | Köket.se") == "Ugnslax"
|
||
|
||
|
||
class TestSlugify:
|
||
def test_transliterates_swedish_characters(self):
|
||
assert slugify("Kycklinggryta med äpple och lök") == "kycklinggryta-med-apple-och-lok"
|
||
|
||
def test_expands_ampersand(self):
|
||
assert slugify("Förrätter & tilltugg") == "forratter-och-tilltugg"
|
||
|
||
|
||
class TestFindEnglishLeftovers:
|
||
def test_flags_untranslated_units(self):
|
||
assert set(find_english_leftovers("2 cups flour, 1 tbsp butter")) == {"cups", "tbsp"}
|
||
|
||
def test_clean_swedish_line_passes(self):
|
||
assert find_english_leftovers("2 dl vetemjöl, 1 msk smör") == []
|
||
|
||
def test_does_not_match_inside_swedish_words(self):
|
||
# "klyfta" contains no English token; substring matching would be wrong here.
|
||
assert find_english_leftovers("1 klyfta vitlök") == []
|
||
|
||
|
||
class TestLooksMangled:
|
||
def test_flags_composed_fraction_from_parser(self):
|
||
assert looks_mangled("47⁄10 liter basmatiris")
|
||
|
||
def test_flags_duplicated_unit(self):
|
||
assert looks_mangled("2 dl dl grädde")
|
||
|
||
def test_clean_line_is_not_mangled(self):
|
||
assert not looks_mangled("4,7 dl basmatiris")
|
||
|
||
|
||
class TestExtractionFailure:
|
||
def test_detects_scraper_placeholder_in_ingredients(self):
|
||
recipe = {"recipeIngredient": [{"display": "Could not detect ingredients"}]}
|
||
assert has_extraction_failure(recipe)
|
||
|
||
def test_clean_recipe_passes(self):
|
||
recipe = {"recipeIngredient": [{"display": "2 dl grädde"}]}
|
||
assert not has_extraction_failure(recipe)
|
||
|
||
|
||
class TestIngredientDisplayLines:
|
||
def test_prefers_display_then_note_then_food(self):
|
||
recipe = {
|
||
"recipeIngredient": [
|
||
{"display": "2 dl grädde", "note": "ignored"},
|
||
{"display": "", "note": "1 gul lök"},
|
||
{"display": "", "note": "", "food": {"name": "salt"}},
|
||
]
|
||
}
|
||
assert ingredient_display_lines(recipe) == ["2 dl grädde", "1 gul lök", "salt"]
|