2026-07-31 07:31:10 +02:00
|
|
|
|
"""The finished-import checklist must fail loudly on every verified failure mode."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from mealie_mcp.verify import is_parsed, verify_recipe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def complete_recipe(**overrides):
|
|
|
|
|
|
"""A recipe that satisfies every check, so each test can break exactly one thing."""
|
|
|
|
|
|
recipe = {
|
|
|
|
|
|
"name": "Kycklinggryta med kokos",
|
|
|
|
|
|
"slug": "kycklinggryta-med-kokos",
|
|
|
|
|
|
"description": "Krämig gryta med kokosmjölk och röd curry.",
|
|
|
|
|
|
"recipeInstructions": [{"text": "Bryn kycklingen."}, {"text": "Häll i kokosmjölken."}],
|
|
|
|
|
|
"recipeIngredient": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"display": "400 g kycklinglårfilé",
|
|
|
|
|
|
"quantity": 400,
|
|
|
|
|
|
"unit": {"id": "u1", "name": "g"},
|
|
|
|
|
|
"food": {"id": "f1", "name": "kycklinglårfilé"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"display": "1 burk kokosmjölk",
|
|
|
|
|
|
"quantity": 1,
|
|
|
|
|
|
"unit": {"id": "u2", "name": "burk"},
|
|
|
|
|
|
"food": {"id": "f2", "name": "kokosmjölk"},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
"image": "abc123",
|
|
|
|
|
|
"recipeCategory": [{"name": "Huvudrätter"}],
|
|
|
|
|
|
"tags": [{"name": "Källa: Köket"}],
|
|
|
|
|
|
"orgURL": "https://www.koket.se/kycklinggryta",
|
2026-08-17 14:52:54 +02:00
|
|
|
|
"recipeServings": 4,
|
|
|
|
|
|
"nutrition": {"calories": "520", "proteinContent": "31", "fatContent": "34",
|
|
|
|
|
|
"carbohydrateContent": "18"},
|
2026-07-31 07:31:10 +02:00
|
|
|
|
}
|
|
|
|
|
|
recipe.update(overrides)
|
|
|
|
|
|
return recipe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def status_of(report, check):
|
|
|
|
|
|
return next(c["status"] for c in report["checks"] if c["check"] == check)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCompleteRecipe:
|
|
|
|
|
|
def test_fully_finished_import_passes(self):
|
|
|
|
|
|
report = verify_recipe(complete_recipe(), image_verified=True)
|
|
|
|
|
|
assert report["complete"] is True
|
|
|
|
|
|
assert report["failed"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCoverImage:
|
|
|
|
|
|
def test_image_field_alone_is_only_a_warning_not_a_pass(self):
|
|
|
|
|
|
# Verified: a non-empty image field does not prove the UI shows a cover.
|
|
|
|
|
|
report = verify_recipe(complete_recipe(), image_verified=False)
|
|
|
|
|
|
assert status_of(report, "cover_image") == "warn"
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_image_fails(self):
|
|
|
|
|
|
report = verify_recipe(complete_recipe(image=None))
|
|
|
|
|
|
assert status_of(report, "cover_image") == "fail"
|
|
|
|
|
|
assert report["complete"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSwedishContent:
|
|
|
|
|
|
def test_english_ingredient_line_fails(self):
|
|
|
|
|
|
recipe = complete_recipe()
|
|
|
|
|
|
recipe["recipeIngredient"][0]["display"] = "2 cups flour"
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert status_of(report, "swedish_ingredients") == "fail"
|
|
|
|
|
|
assert report["complete"] is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_english_instructions_fail(self):
|
|
|
|
|
|
recipe = complete_recipe(recipeInstructions=[{"text": "Add 2 tbsp of butter"}])
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert status_of(report, "swedish_prose") == "fail"
|
|
|
|
|
|
|
|
|
|
|
|
def test_swedish_title_only_is_not_enough(self):
|
|
|
|
|
|
# Rule: translation is not done until the ingredient lines themselves are Swedish.
|
|
|
|
|
|
recipe = complete_recipe()
|
|
|
|
|
|
recipe["recipeIngredient"][1]["display"] = "1 clove garlic"
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert report["complete"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseState:
|
|
|
|
|
|
def test_unstructured_ingredients_leave_recipe_unparsed(self):
|
|
|
|
|
|
recipe = complete_recipe(
|
|
|
|
|
|
recipeIngredient=[{"display": "400 g kycklinglårfilé"}, {"display": "1 burk kokosmjölk"}]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert is_parsed(recipe) is False
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert status_of(report, "ingredients_parsed") == "fail"
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_recipe_is_not_parsed(self):
|
|
|
|
|
|
assert is_parsed({"recipeIngredient": []}) is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_structured_ingredients_are_parsed(self):
|
|
|
|
|
|
assert is_parsed(complete_recipe()) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadability:
|
|
|
|
|
|
def test_parser_mangled_display_fails_even_when_structured(self):
|
|
|
|
|
|
# Structure is worthless if the human-facing line is garbage.
|
|
|
|
|
|
recipe = complete_recipe()
|
|
|
|
|
|
recipe["recipeIngredient"][0]["display"] = "47⁄10 liter basmatiris"
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert status_of(report, "ingredient_lines_readable") == "fail"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractionPlaceholders:
|
|
|
|
|
|
def test_could_not_detect_placeholder_fails(self):
|
|
|
|
|
|
recipe = complete_recipe()
|
|
|
|
|
|
recipe["recipeIngredient"][0]["display"] = "Could not detect ingredients"
|
|
|
|
|
|
report = verify_recipe(recipe, image_verified=True)
|
|
|
|
|
|
assert status_of(report, "no_extraction_placeholders") == "fail"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTaxonomy:
|
|
|
|
|
|
def test_untagged_recipe_fails_without_a_reason(self):
|
|
|
|
|
|
report = verify_recipe(
|
|
|
|
|
|
complete_recipe(recipeCategory=[], tags=[]), image_verified=True
|
|
|
|
|
|
)
|
|
|
|
|
|
assert status_of(report, "taxonomy") == "fail"
|
|
|
|
|
|
|
|
|
|
|
|
def test_deliberate_skip_passes(self):
|
|
|
|
|
|
report = verify_recipe(
|
|
|
|
|
|
complete_recipe(recipeCategory=[], tags=[]),
|
|
|
|
|
|
image_verified=True,
|
|
|
|
|
|
taxonomy_skipped_reason="Fredrik sorterar den manuellt",
|
|
|
|
|
|
)
|
|
|
|
|
|
assert status_of(report, "taxonomy") == "pass"
|
|
|
|
|
|
assert report["complete"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 14:52:54 +02:00
|
|
|
|
class TestNutrition:
|
|
|
|
|
|
def test_missing_nutrition_fails(self):
|
|
|
|
|
|
report = verify_recipe(complete_recipe(nutrition={}), image_verified=True)
|
|
|
|
|
|
assert status_of(report, "nutrition") == "fail"
|
|
|
|
|
|
assert report["complete"] is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_values_without_a_serving_count_fail(self):
|
|
|
|
|
|
# Mealie shows nutrition per serving, so figures with recipeServings 0
|
|
|
|
|
|
# are a numerator without a denominator — the state the TikTok import left.
|
|
|
|
|
|
report = verify_recipe(complete_recipe(recipeServings=0), image_verified=True)
|
|
|
|
|
|
assert status_of(report, "nutrition") == "fail"
|
|
|
|
|
|
assert "recipeServings" in next(
|
|
|
|
|
|
c["detail"] for c in report["checks"] if c["check"] == "nutrition"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 07:31:10 +02:00
|
|
|
|
class TestAttribution:
|
|
|
|
|
|
def test_missing_source_warns_but_does_not_block(self):
|
|
|
|
|
|
report = verify_recipe(
|
|
|
|
|
|
complete_recipe(orgURL=None, extras={}), image_verified=True
|
|
|
|
|
|
)
|
|
|
|
|
|
assert status_of(report, "attribution") == "warn"
|
|
|
|
|
|
assert report["complete"] is True
|