Initial commit: CookIT recipe collection and week planner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 22:55:09 +01:00
commit 7d7a0bc60b
173 files changed
+22803

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import pytest
from cookit_import import parse
@pytest.mark.parametrize(
"value, expected",
[
(275, (275, None)),
(1.5, (1.5, None)),
(0.08333333333333333, (0.083, None)),
("0,5", (0.5, None)),
("½", (0.5, None)),
("1/2", (0.5, None)),
("1 1/2", (1.5, None)),
("3/4", (0.75, None)),
("2-3", (2, 3)),
("10 - 12", (10, 12)),
("200-250", (200, 250)),
(None, (None, None)),
(0, (None, None)),
],
)
def test_parse_amount(value, expected):
assert parse.parse_amount(value) == expected
@pytest.mark.parametrize("value", ["opt. als Topping", "(2", "ODER"])
def test_parse_amount_rejects_text(value):
assert parse.parse_amount(value) is None
@pytest.mark.parametrize(
"value, key",
[
("Gramm", "gram"),
("Mililiter", "milliliter"),
("milliliter", "milliliter"),
("Zehe/n", "clove"),
("Zehen", "clove"),
("Kleine", "small"),
("kleine", "small"),
("Große", "large"),
("Zweig (e)", "sprig"),
("Msp.", "knifeTip"),
("Etwas", "some"),
("Tassen", "cup"),
],
)
def test_parse_unit(value, key):
assert parse.parse_unit(value) == key
def test_parse_unit_unknown():
assert parse.parse_unit("Fett") is None
@pytest.mark.parametrize(
"value, expected",
[("100 g", (100, "gram")), ("125 ml", (125, "milliliter")), ("1 TL", (1, "teaspoon")), ("1 Pkt.", (1, "pack")), ("3", (3, None))],
)
def test_split_amount_unit(value, expected):
assert parse.split_amount_unit(value) == expected
@pytest.mark.parametrize(
"value, expected",
[(30, 30), ("45 Minuten", 45), ("2 Stunden", 120), ("1h 30min", 90), ("0 Minuten", None), (None, None), ("über Nacht", "über Nacht")],
)
def test_parse_minutes(value, expected):
assert parse.parse_minutes(value) == expected
@pytest.mark.parametrize(
"value, number, remark",
[
(4, 4, None),
(3.5, 3.5, None),
("6 Portionen", 6, None),
("4 Personen", 4, None),
("8 Stk.", 8, "8 Stk."),
("2 Rollen;\neine Rolle entspricht 8 Scheiben", 2, "2 Rollen;\neine Rolle entspricht 8 Scheiben"),
(None, None, None),
],
)
def test_parse_servings(value, number, remark):
s = parse.parse_servings(value)
assert (s.value, s.remark) == (number, remark)
def test_calories_basis_threshold():
assert parse.calories_basis(1500) == "perServing"
assert parse.calories_basis(1501) == "wholeRecipe"
def test_extract_urls():
urls, rest = parse.extract_urls("Quelle: https://sallys-blog.de/rezepte/focaccia")
assert urls == ["https://sallys-blog.de/rezepte/focaccia"]
assert rest == "Quelle:"
class TestSplitSteps:
def test_blank_lines_separate_steps(self):
sections = parse.split_steps("\nZutaten mischen.\n\nTeig gehen lassen.\n\n\nBacken.")
assert [(s.title, s.steps) for s in sections] == [(None, ["Zutaten mischen.", "Teig gehen lassen.", "Backen."])]
def test_heading_line_starts_a_section(self):
sections = parse.split_steps("Vorteig\nVerrühre die Zutaten.\n\nTeig\nFüge das Mehl hinzu.")
assert [(s.title, s.steps) for s in sections] == [
("Vorteig", ["Verrühre die Zutaten."]),
("Teig", ["Füge das Mehl hinzu."]),
]
def test_lone_heading_paragraph(self):
sections = parse.split_steps("Soße:\n\nAlles verrühren.\n\nAbschmecken.")
assert [(s.title, s.steps) for s in sections] == [("Soße", ["Alles verrühren.", "Abschmecken."])]
def test_inline_heading(self):
sections = parse.split_steps("Salat:Die Zwiebel schneiden.\n\nSoße:Mayo verrühren.")
assert [(s.title, s.steps) for s in sections] == [("Salat", ["Die Zwiebel schneiden."]), ("Soße", ["Mayo verrühren."])]
def test_single_paragraph_splits_on_lines(self):
sections = parse.split_steps(" Mehl und Salz mischen.\n Wasser zugeben und kneten.")
assert sections[0].steps == ["Mehl und Salz mischen.", "Wasser zugeben und kneten."]
def test_short_last_line_stays_a_step(self):
sections = parse.split_steps("Alles anbraten.\n\nServieren")
assert sections[0].steps == ["Alles anbraten.", "Servieren"]
def test_strips_enumeration(self):
sections = parse.split_steps("1. Wasser kochen.\n\n2) Nudeln garen.")
assert sections[0].steps == ["Wasser kochen.", "Nudeln garen."]
def test_empty(self):
assert parse.split_steps("\n\n ") == []
+178
View File
@@ -0,0 +1,178 @@
from cookit_import.recipe import Reason, build_recipe, is_recipe_sheet, recipe_id
from cookit_import.workbook import Sheet
TEMPLATE = {
"C1": "Testrezept",
"F1": "Hauptgericht",
"G1": "Rezeptindex",
"C2": "Menge",
"D2": "Maßeinheit",
"E2": "Zutat",
"F2": "Anleitungen",
"G2": "Vorbereitungszeit",
"B5": "Kalorien",
"G6": "Kochzeit",
"B8": "Rezept-Stichwörter",
"G10": "Ergebnis (Portionen)",
"B11": "Kommentare",
"B12": "Löschen Sie dieses Rezeptarbeitsblatt nicht.",
}
def sheet(title="Testrezept", comments=None, **cells) -> Sheet:
return Sheet(title, {**TEMPLATE, **cells}, comments or {})
def lines(recipe):
return [[(l["amount"], l["unit"], l["name"]) for l in g["lines"]] for g in recipe["ingredientGroups"]]
def test_recognises_recipe_sheets_and_skips_template_ones():
assert is_recipe_sheet(sheet())
assert not is_recipe_sheet(sheet(title="Leeres Rezept"))
assert not is_recipe_sheet(sheet(title="Spinat-Feta-Pizza"))
assert not is_recipe_sheet(Sheet("Speiseplan", {"C2": "Montag"}))
def test_basic_fields():
r = build_recipe(sheet(G3=20, G7="15 Minuten", G11=4, B6=775, B9="Tomaten, Nudeln, tomaten, Käse", F3="Kochen.\n\nEssen."))
assert r["id"] == recipe_id("Testrezept")
assert r["name"] == "Testrezept"
assert r["course"] == "Hauptgericht"
assert r["tags"] == ["Tomaten", "Nudeln", "Käse"]
assert (r["prepMinutes"], r["cookMinutes"], r["servings"]) == (20, 15, 4)
assert r["calories"] == {"kcal": 775, "basis": "perServing"}
assert r["stepSections"] == [{"title": None, "steps": ["Kochen.", "Essen."]}]
assert r["notes"] is None
assert r["reviewReasons"] == []
def test_ids_are_stable_per_sheet():
assert recipe_id("Focaccia") == recipe_id("Focaccia") != recipe_id("Brot")
def test_placeholder_course_and_missing_servings():
r = build_recipe(sheet(F1="Gang"))
assert r["course"] is None
assert Reason.NO_SERVINGS in r["reviewReasons"]
def test_longer_name_wins_and_template_title_is_ignored():
assert build_recipe(sheet(title="Flammkuchen, low carb", C1="Flammkuchen"))["name"] == "Flammkuchen, low carb"
assert build_recipe(sheet(title="Kohlrabi-Kartoffel-Auflauf", C1="Leeres Rezept"))["name"] == "Kohlrabi-Kartoffel-Auflauf"
def test_blank_rows_separate_ingredient_groups_and_units_are_canonical():
r = build_recipe(sheet(G11=1, C3=300, D3="Gramm", E3="Wasser", C4=1, D4="Teelöffel", E4="Zucker", E6="Rosmarin"))
assert lines(r) == [[(300, "gram", "Wasser"), (1, "teaspoon", "Zucker")], [(None, None, "Rosmarin")]]
assert [g["name"] for g in r["ingredientGroups"]] == [None, None]
def test_rescaling_helper_numbers_are_dropped_silently():
r = build_recipe(sheet(G11=4, C3=350, D3="Gramm", E3="Rigatoni", C33=350, D33=87.5, C34=1, D34=0.25, C10=125))
assert lines(r) == [[(350, "gram", "Rigatoni")]]
assert r["reviewReasons"] == []
def test_oder_row_starts_alternative_group():
r = build_recipe(sheet(G11=8, C3=375, D3="Gramm", E3="Mehl", C5="ODER", C7=295, D7="Gramm", E7="Mehl", C8=80, D8="Gramm", E8="Proteinpulver"))
assert [g["name"] for g in r["ingredientGroups"]] == [None, "Alternative"]
assert Reason.ALTERNATIVE in r["reviewReasons"]
def test_duplicate_ingredient_list_is_dropped():
first = dict(C3=100, D3="Gramm", E3="Mehl", C4=3, E4="Eier", C5=125, D5="Milliliter", E5="Milch")
copy = dict(D20="100 g", E20="Mehl", D21="3", E21="Eier", D22="125 ml", E22="Milch")
r = build_recipe(sheet(G11=2, **first, **copy))
assert len(r["ingredientGroups"]) == 1
assert Reason.DUPLICATE_LIST in r["reviewReasons"]
def test_amount_typed_into_unit_column():
r = build_recipe(sheet(G11=2, D3="100 g", E3="Mehl", D4="1 TL", E4="Zucker"))
assert lines(r) == [[(100, "gram", "Mehl"), (1, "teaspoon", "Zucker")]]
def test_ranges_fractions_and_unknown_amounts():
r = build_recipe(sheet(G11=2, C3="2-3", D3="Esslöffel", E3="Kakao", C4="½", D4="Bund", E4="Schnittlauch", C5="opt. als Topping", E5="Sesam"))
first, second, third = r["ingredientGroups"][0]["lines"]
assert (first["amount"], first["amountMax"]) == (2, 3)
assert second["amount"] == 0.5
assert (third["amount"], third["remark"]) == (None, "opt. als Topping")
assert Reason.UNKNOWN_AMOUNT.format(amount="opt. als Topping") in r["reviewReasons"]
def test_parenthesised_line_is_optional():
r = build_recipe(sheet(G11=4, C3="(2", D3="Teelöffel", E3="Oregano)"))
line = r["ingredientGroups"][0]["lines"][0]
assert (line["amount"], line["unit"], line["name"], line["remark"]) == (2, "teaspoon", "Oregano", "optional")
def test_unknown_unit_moves_into_name():
r = build_recipe(sheet(G11=2, C3=2, D3="Würfel", E3="Brühe"))
assert lines(r) == [[(2, None, "Würfel Brühe")]]
assert Reason.UNKNOWN_UNIT.format(unit="Würfel") in r["reviewReasons"]
def test_comment_group_markers_name_groups_and_other_comments_become_remarks():
comments = {"C3": "Zutaten für die Rouladen", "C5": "Ab hier: \nZutaten für die Sauce", "E4": "kleiner Espresso-Löffel"}
r = build_recipe(sheet(G11=6, comments=comments, C3=6, E3="Rinderrouladen", C4=1, D4="Teelöffel", E4="Senf", C5=50, D5="Gramm", E5="Butter"))
assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce"]
assert r["ingredientGroups"][0]["lines"][1]["remark"] == "kleiner Espresso-Löffel"
def test_pro_portion_comment_settles_calories_basis():
r = build_recipe(sheet(G11=4, B6=662, comments={"B6": "Pro Portion"}))
assert r["calories"] == {"kcal": 662, "basis": "perServing"}
assert r["reviewReasons"] == []
def test_high_calories_mean_whole_recipe():
r = build_recipe(sheet(G11=1, B6=3831))
assert r["calories"]["basis"] == "wholeRecipe"
def test_comment_b12_and_stray_cells_go_to_notes_and_urls_to_sources():
r = build_recipe(
sheet(
G11=1,
B12="Springform 26 cm",
I12="ca.",
J12="15",
K12="Dampfnudeln",
B17="https://sallys-blog.de/rezepte/focaccia",
)
)
assert r["notes"] == "Springform 26 cm\n\nca. 15 Dampfnudeln"
assert r["sources"] == ["https://sallys-blog.de/rezepte/focaccia"]
assert Reason.STRAY_TEXT in r["reviewReasons"]
def test_template_comment_is_not_a_note():
r = build_recipe(sheet(G11=1))
assert r["notes"] is None
assert r["reviewReasons"] == []
def test_nutrition_rows_in_ingredient_columns_become_notes():
r = build_recipe(sheet(G11=1, C3=3, E3="Eier", D18="Kalorien/ 100g", E18="263.47", D19="Fett", E19="17.92"))
assert lines(r) == [[(3, None, "Eier")]]
assert r["notes"] == "Kalorien/ 100g 263.47\nFett 17.92"
def test_instructions_split_over_cells_are_merged_and_flagged():
r = build_recipe(sheet(G11=6, F3="Teig rühren.", F19="Spätzle abschrecken."))
assert r["stepSections"][0]["steps"] == ["Teig rühren.", "Spätzle abschrecken."]
assert Reason.MERGED_INSTRUCTIONS in r["reviewReasons"]
def test_long_step_is_flagged():
r = build_recipe(sheet(G11=1, F3="Rühren. " * 100))
assert Reason.LONG_STEP in r["reviewReasons"]
def test_unreadable_time_goes_to_notes():
r = build_recipe(sheet(G11=1, G3="über Nacht"))
assert r["prepMinutes"] is None
assert r["notes"] == "Vorbereitungszeit: über Nacht"
assert Reason.UNREADABLE_TIME in r["reviewReasons"]
+63
View File
@@ -0,0 +1,63 @@
"""Checks against the real (private, untracked) workbook. Skipped when it isn't there."""
import io
import json
import os
import zipfile
import pytest
from PIL import Image
from cookit_import.bundle import MAX_PHOTO_EDGE, write_bundle
from cookit_import.recipe import build_recipe, is_recipe_sheet, real_photos, template_photo_hashes
from cookit_import.workbook import read_sheets
WORKBOOK = os.environ.get("COOKIT_WORKBOOK", os.path.join(os.path.dirname(__file__), "..", "..", "..", "Wochenspeiseplan.xlsm"))
pytestmark = pytest.mark.skipif(not os.path.exists(WORKBOOK), reason="Wochenspeiseplan.xlsm not available")
@pytest.fixture(scope="module")
def recipes():
all_sheets = read_sheets(WORKBOOK)
placeholders = template_photo_hashes(all_sheets)
return {s.title: (build_recipe(s), real_photos(s, placeholders)) for s in all_sheets if is_recipe_sheet(s)}
def test_all_recipe_sheets_found(recipes):
assert len(recipes) == 214
assert "Leeres Rezept" not in recipes and "Spinat-Feta-Pizza" not in recipes
def test_focaccia(recipes):
r, photos = recipes["Focaccia"]
assert [s["title"] for s in r["stepSections"]][:2] == ["Vorteig", "Teig"]
assert len(r["ingredientGroups"]) == 3
assert len(r["sources"]) == 2
assert r["notes"] == "22x22 cm eckige Form"
assert photos == [] # only the template's stock picture was on the sheet
def test_template_stock_photo_is_dropped_but_real_photos_kept(recipes):
without = [title for title, (_, photos) in recipes.items() if not photos]
assert 25 <= len(without) <= 35
assert len(recipes["Melonen-Minze-Shake"][1]) == 3
def test_rinderrouladen_groups_from_comments(recipes):
r, _ = recipes["Klassische Rinderrouladen"]
assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce", "Zusätzliche Zutaten"]
def test_bundle_round_trip(recipes, tmp_path):
subset = [recipes[t] for t in ("Focaccia", "Melonen-Minze-Shake", "Hähnchen")]
out = tmp_path / "bundle.zip"
write_bundle(str(out), [(dict(r), photos) for r, photos in subset])
with zipfile.ZipFile(out) as z:
manifest = json.loads(z.read("bundle.json"))
assert manifest["format"] == "cookit.recipe-bundle" and manifest["version"] == 1
shake = next(r for r in manifest["recipes"] if r["name"] == "Melonen-Minze-Shake")
assert len(shake["photos"]) == 3
for name in shake["photos"]:
img = Image.open(io.BytesIO(z.read(name)))
assert img.format == "JPEG" and max(img.size) <= MAX_PHOTO_EDGE