Files
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:13 +01:00

78 lines
3.1 KiB
Python

from decimal import Decimal
import pytest
from recipes.parsing import (
format_amount,
ingredients_to_text,
parse_ingredient_line,
parse_ingredients,
parse_steps,
parse_tags,
scale_amount,
)
@pytest.mark.parametrize(
"line, amount, amount_max, unit, name, note",
[
("200 g Mehl, gesiebt", "200", None, "g", "Mehl", "gesiebt"),
("200g Mehl", "200", None, "g", "Mehl", ""),
("1,5l Milch", "1.5", None, "l", "Milch", ""),
("1 ½ TL Salz", "1.5", None, "TL", "Salz", ""),
("1/2 Bund Petersilie", "0.5", None, "Bund", "Petersilie", ""),
("2 1/2 Tassen Reis", "2.5", None, "Tassen", "Reis", ""),
("2-3 Zehen Knoblauch", "2", "3", "Zehen", "Knoblauch", ""),
("2 bis 3 EL Öl", "2", "3", "EL", "Öl", ""),
("2 Eier", "2", None, "", "Eier", ""),
("3 Essl. Zucker", "3", None, "EL", "Zucker", ""),
("- 1 Prise Salz", "1", None, "Prise", "Salz", ""),
("½ Zitrone, nur Saft", "0.5", None, "", "Zitrone", "nur Saft"),
("Salz und Pfeffer", None, None, "", "Salz und Pfeffer", ""),
("1 Dose Tomaten (400 g)", "1", None, "Dose", "Tomaten (400 g)", ""),
],
)
def test_parse_ingredient_line(line, amount, amount_max, unit, name, note):
item = parse_ingredient_line(line)
assert item.amount == (Decimal(amount) if amount else None)
assert item.amount_max == (Decimal(amount_max) if amount_max else None)
assert (item.unit, item.name, item.note) == (unit, name, note)
def test_parse_ingredients_with_groups():
items = parse_ingredients("Für den Teig:\n200 g Mehl\n\nFür die Soße:\n1 Dose Tomaten\n")
assert [(i.group, i.name) for i in items] == [("Für den Teig", "Mehl"), ("Für die Soße", "Tomaten")]
def test_ingredient_text_roundtrip():
text = "200 g Mehl, gesiebt\n2-3 Zehen Knoblauch\n\nFür die Soße:\n½ TL Salz\nPfeffer"
items = parse_ingredients(text)
assert parse_ingredients(ingredients_to_text(items)) == items
def test_parse_steps_by_blank_lines():
assert parse_steps("Ofen vorheizen.\nAuf 180 °C.\n\n2. Mischen.") == ["Ofen vorheizen. Auf 180 °C.", "Mischen."]
def test_parse_steps_by_lines_and_strip_numbers():
assert parse_steps("1. Schneiden\n2) Braten\nSchritt 3: Servieren") == ["Schneiden", "Braten", "Servieren"]
def test_parse_tags_lowercase_unique():
assert parse_tags("Pasta, #Vegetarisch, Schnell Gemacht ,pasta,") == ["pasta", "vegetarisch", "schnell gemacht"]
@pytest.mark.parametrize(
"value, expected",
[("2", "2"), ("0.5", "½"), ("1.5", "1 ½"), ("0.333", "⅓"), ("0.25", "¼"), ("2.37", "2,37"), ("1.999", "2")],
)
def test_format_amount(value, expected):
assert format_amount(Decimal(value)) == expected
def test_scale_amount_normalises_metric():
assert scale_amount(Decimal(600), None, "g", Decimal(2)) == (Decimal("1.2"), None, "kg")
assert scale_amount(Decimal(250), None, "ml", Decimal("0.5")) == (Decimal(125), None, "ml")
assert scale_amount(Decimal(2), Decimal(3), "EL", Decimal(2)) == (Decimal(4), Decimal(6), "EL")
assert scale_amount(None, None, "", Decimal(2)) == (None, None, "")