2026-07-31 07:31:10 +02:00
|
|
|
|
"""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,
|
2026-07-31 08:38:11 +02:00
|
|
|
|
food_matches_line,
|
2026-07-31 07:31:10 +02:00
|
|
|
|
has_extraction_failure,
|
|
|
|
|
|
ingredient_display_lines,
|
2026-07-31 08:38:11 +02:00
|
|
|
|
line_remainder,
|
2026-07-31 07:31:10 +02:00
|
|
|
|
looks_mangled,
|
2026-07-31 08:38:11 +02:00
|
|
|
|
match_unit,
|
2026-07-31 07:31:10 +02:00
|
|
|
|
normalize_for_parser,
|
2026-07-31 08:38:11 +02:00
|
|
|
|
same_line,
|
2026-07-31 07:31:10 +02:00
|
|
|
|
slugify,
|
2026-07-31 08:38:11 +02:00
|
|
|
|
unit_token,
|
2026-07-31 07:31:10 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
2026-07-31 08:38:11 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|