grocery-bot

Log | Files | Refs | README

commit c4e3cd3c37ee163f0be48a8290ef8cbcf334f313
parent 638708bbc373add37a3eb5238d83a25f7346c820
Author: Chris Roberts <chris.roberts@learningunix.net>
Date:   Fri,  3 Jul 2026 06:00:15 -0500

Build initial bot: SQLite schema, CRUD, merge logic, and command handlers

Implements the SPEC.md initial scope: recipes/ingredients/staples/grocery
list tables with cascade delete, quantity-aware merge logic (sum on
matching name+unit, keep separate otherwise), and all seven bot commands.

Also drops the nut-free constraint (single-user household, not needed)
and pins python-telegram-bot to v22+ after finding v20.x is broken on
Python 3.14.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Diffstat:
A.env.example | 2++
A.gitignore | 7+++++++
MCLAUDE.md | 22+++++++++++-----------
MSPEC.md | 11+++++------
Aanton-bot.service | 20++++++++++++++++++++
Abot.py | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adb.py | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amodels.py | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aparsing.py | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apytest.ini | 2++
Arequirements.txt | 3+++
Atests/test_parsing.py | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 705 insertions(+), 17 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,2 @@ +TELEGRAM_BOT_TOKEN= +ANTON_DB_PATH=anton.db diff --git a/.gitignore b/.gitignore @@ -0,0 +1,7 @@ +.env +*.db +__pycache__/ +*.pyc +.venv/ +venv/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md @@ -6,22 +6,22 @@ A self-hosted Telegram bot for tracking recipes, managing a weekly staples list, and generating grocery lists. Runs as a long-polling service in an LXC/VM on Proxmox, alongside the rest of the homelab stack. -## Dietary Constraints (hard requirements, not preferences) +## Dietary Constraints - **Ketogenic**: recipes/ingredients should be tagged `keto` automatically based on carb content where determinable, but always allow manual override. -- **Nut-free**: this is an allergy, not a preference. Any ingredient matching - the nut list below must hard-block the recipe from being marked nut-free, - and the bot should warn (not silently tag) when an ingredient is ambiguous - (e.g. "may contain tree nuts"). - - Nut list: almond, walnut, pecan, cashew, pistachio, hazelnut, macadamia, - brazil nut, pine nut, peanut (technically a legume but treat as a nut - here). Keep this list in a single constant, not duplicated across files. + +Nut-free tracking was considered and deliberately dropped (2026-07-02): +single-user household, user doesn't eat nuts, so nut-containing recipes are +very unlikely to enter the system in the first place. Not worth the schema/ +logic complexity for this tool. ## Tech Stack - Python 3.11+ -- `python-telegram-bot` (async, v20+) — long polling, no public webhook needed +- `python-telegram-bot` (async, v22+) — long polling, no public webhook needed. + v20.x is broken on Python 3.14 (AttributeError building `Updater`, + confirmed 2026-07-03) — pin to v22+ if the system Python is 3.14. - SQLite via `sqlite3` stdlib or `sqlmodel` if an ORM is wanted — keep it simple, this doesn't need SQLAlchemy's full weight - `systemd` service file for deployment (pattern-match the existing @@ -29,7 +29,7 @@ LXC/VM on Proxmox, alongside the rest of the homelab stack. ## Data Model -- `recipes(id, name, servings, instructions, source, is_keto, is_nut_free)` +- `recipes(id, name, servings, instructions, source, is_keto)` - `ingredients(id, recipe_id, name, quantity, unit, category)` - `staples(id, name, default_quantity, unit, category)` - `grocery_list_items(id, name, quantity, unit, category, source)` @@ -73,7 +73,7 @@ LXC/VM on Proxmox, alongside the rest of the homelab stack. - Web UI — SQLite backend should make this easy to bolt on later if wanted - Multi-user support — this is a single-household tool -- Nutrition/macro calculation beyond keto/nut-free tagging +- Nutrition/macro calculation beyond keto tagging ## Testing diff --git a/SPEC.md b/SPEC.md @@ -3,8 +3,8 @@ ## Overview A Telegram bot, self-hosted on the homelab, for tracking recipes with -dietary tags (keto, nut-free), maintaining a weekly staples list, and -generating merged grocery lists on demand. +dietary tags (keto), maintaining a weekly staples list, and generating +merged grocery lists on demand. ## Why Telegram over Slack @@ -57,10 +57,9 @@ See `CLAUDE.md` for full schema. Summary: ## Dietary Safety Notes -Nut-free is treated as an allergy constraint, not a preference — the bot -should never silently mark something nut-free if any ingredient is -ambiguous. Keto tagging can be looser (auto-suggested, manually -correctable). +Nut-free tracking was dropped as a requirement (single-user household, +user doesn't eat nuts, so nut recipes are unlikely to enter the system). +Keto tagging is auto-suggested and manually correctable. ## Future Ideas (not in initial build) diff --git a/anton-bot.service b/anton-bot.service @@ -0,0 +1,20 @@ +[Unit] +Description=Anton Recipe & Grocery Telegram Bot +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Placeholders below -- adjust to match your actual LXC layout. Not +# verified against your existing homelab systemd unit conventions. +User=anton +WorkingDirectory=/opt/anton +EnvironmentFile=/opt/anton/.env +ExecStart=/opt/anton/venv/bin/python /opt/anton/bot.py +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/bot.py b/bot.py @@ -0,0 +1,239 @@ +"""Telegram bot entrypoint: command handlers wired to db.py / parsing.py.""" +from __future__ import annotations + +import logging +import os +import random + +from dotenv import load_dotenv +from telegram import Update +from telegram.ext import Application, CommandHandler, ContextTypes + +import db +import parsing +from models import get_connection, init_db + +load_dotenv() + +TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +ADDRECIPE_HELP = ( + "Paste a recipe after /addrecipe, e.g.:\n\n" + "Name: Beef Chili\n" + "Servings: 4\n" + "Source: https://example.com\n" + "Keto: yes\n" + "Ingredients:\n" + "1 | lb | ground beef | meat\n" + "1 | cup | diced tomatoes | pantry\n" + "Instructions:\n" + "Brown the beef, add tomatoes, simmer.\n\n" + "Keto: is optional -- omit it to auto-suggest from ingredients." +) + + +def _conn(): + conn = get_connection() + init_db(conn) + return conn + + +async def addrecipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + _, _, body = update.message.text.partition("\n") + if not body.strip(): + await update.message.reply_text(ADDRECIPE_HELP) + return + + try: + parsed = parsing.parse_recipe_text(body) + except ValueError as exc: + await update.message.reply_text(f"Couldn't parse that recipe: {exc}") + return + + conn = _conn() + try: + recipe_id = db.add_recipe( + conn, + name=parsed["name"], + servings=parsed["servings"], + instructions=parsed["instructions"], + source=parsed["source"], + is_keto=parsed["is_keto"], + ) + for ing in parsed["ingredients"]: + db.add_ingredient( + conn, + recipe_id=recipe_id, + name=ing["name"], + quantity=ing["quantity"], + unit=ing["unit"], + category=ing["category"], + ) + finally: + conn.close() + + keto_note = "keto" if parsed["is_keto"] else "not flagged keto" + await update.message.reply_text( + f"Added '{parsed['name']}' with {len(parsed['ingredients'])} " + f"ingredient(s) ({keto_note})." + ) + + +async def recipes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + tag = context.args[0] if context.args else None + conn = _conn() + try: + rows = db.list_recipes(conn, tag=tag) + finally: + conn.close() + + if not rows: + await update.message.reply_text("No recipes found.") + return + + lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows] + await update.message.reply_text("\n".join(lines)) + + +async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + conn = _conn() + try: + if context.args and context.args[0].lower() == "add": + name = " ".join(context.args[1:]).strip() + if not name: + await update.message.reply_text("Usage: /staples add <item>") + return + db.add_staple(conn, name=name, default_quantity=None, unit=None, category=None) + await update.message.reply_text(f"Added staple: {name}") + return + + if context.args and context.args[0].lower() == "remove": + name = " ".join(context.args[1:]).strip() + if not name: + await update.message.reply_text("Usage: /staples remove <item>") + return + db.remove_staple(conn, name) + await update.message.reply_text(f"Removed staple: {name}") + return + + rows = db.list_staples(conn) + finally: + conn.close() + + if not rows: + await update.message.reply_text("No staples set.") + return + + lines = [f"- {r['name']}" for r in rows] + await update.message.reply_text("\n".join(lines)) + + +async def grocerylist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + _, _, rest = update.message.text.partition(" ") + recipe_names = [n.strip() for n in rest.split(",") if n.strip()] + + conn = _conn() + try: + items = [ + parsing.Item( + name=s["name"], quantity=s["default_quantity"], + unit=s["unit"], category=s["category"], source="staple", + ) + for s in db.list_staples(conn) + ] + + not_found = [] + for rn in recipe_names: + recipe = db.get_recipe_by_name(conn, rn) + if not recipe: + not_found.append(rn) + continue + for ing in db.list_ingredients_for_recipe(conn, recipe["id"]): + items.append(parsing.Item( + name=ing["name"], quantity=ing["quantity"], + unit=ing["unit"], category=ing["category"], source="recipe", + )) + + merged = parsing.merge_ingredients(items) + + db.clear_grocery_list(conn) + for item in merged: + db.add_grocery_item( + conn, name=item.name, quantity=item.quantity, + unit=item.unit, category=item.category, source=item.source, + ) + finally: + conn.close() + + lines = [f"- {(item.quantity or '')} {(item.unit or '')} {item.name}".strip() for item in merged] + reply = "\n".join(lines) if lines else "Nothing to list." + if not_found: + reply += f"\n\n(Recipe(s) not found: {', '.join(not_found)})" + await update.message.reply_text(reply) + + +async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + _, _, item_name = update.message.text.partition(" ") + item_name = item_name.strip() + if not item_name: + await update.message.reply_text("Usage: /add <item>") + return + + conn = _conn() + try: + db.add_grocery_item( + conn, name=item_name, quantity=None, unit=None, + category=None, source="freeform", + ) + finally: + conn.close() + await update.message.reply_text(f"Added '{item_name}' to the grocery list.") + + +async def clearlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + conn = _conn() + try: + db.clear_grocery_list(conn) + finally: + conn.close() + await update.message.reply_text("Grocery list cleared.") + + +async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + tag = context.args[0] if context.args else None + conn = _conn() + try: + rows = db.list_recipes(conn, tag=tag) + finally: + conn.close() + + if not rows: + await update.message.reply_text("No recipes to choose from.") + return + + choice = random.choice(rows) + await update.message.reply_text(f"How about: {choice['name']}?") + + +def main() -> None: + conn = _conn() + conn.close() + + app = Application.builder().token(TOKEN).build() + app.add_handler(CommandHandler("addrecipe", addrecipe_cmd)) + app.add_handler(CommandHandler("recipes", recipes_cmd)) + app.add_handler(CommandHandler("staples", staples_cmd)) + app.add_handler(CommandHandler("grocerylist", grocerylist_cmd)) + app.add_handler(CommandHandler("add", add_cmd)) + app.add_handler(CommandHandler("clearlist", clearlist_cmd)) + app.add_handler(CommandHandler("random", random_cmd)) + + logger.info("Starting bot (long polling)...") + app.run_polling() + + +if __name__ == "__main__": + main() diff --git a/db.py b/db.py @@ -0,0 +1,103 @@ +"""CRUD functions for recipes, ingredients, staples, and grocery list items.""" +from __future__ import annotations + +import sqlite3 + + +# --- recipes --- + +def add_recipe(conn, name, servings, instructions, source, is_keto) -> int: + cur = conn.execute( + "INSERT INTO recipes (name, servings, instructions, source, is_keto) " + "VALUES (?, ?, ?, ?, ?)", + (name, servings, instructions, source, int(is_keto)), + ) + conn.commit() + return cur.lastrowid + + +def get_recipe(conn, recipe_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + + +def get_recipe_by_name(conn, name: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM recipes WHERE name = ? COLLATE NOCASE", (name,) + ).fetchone() + + +def list_recipes(conn, tag: str | None = None) -> list[sqlite3.Row]: + if tag == "keto": + return conn.execute("SELECT * FROM recipes WHERE is_keto = 1").fetchall() + return conn.execute("SELECT * FROM recipes").fetchall() + + +def delete_recipe(conn, recipe_id: int) -> None: + # Ingredients cascade-delete automatically (see models.py schema / PRAGMA). + conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + conn.commit() + + +# --- ingredients --- + +def add_ingredient(conn, recipe_id, name, quantity, unit, category) -> int: + cur = conn.execute( + "INSERT INTO ingredients (recipe_id, name, quantity, unit, category) " + "VALUES (?, ?, ?, ?, ?)", + (recipe_id, name, quantity, unit, category), + ) + conn.commit() + return cur.lastrowid + + +def list_ingredients_for_recipe(conn, recipe_id: int) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM ingredients WHERE recipe_id = ?", (recipe_id,) + ).fetchall() + + +# --- staples --- + +def add_staple(conn, name, default_quantity, unit, category) -> int: + cur = conn.execute( + "INSERT INTO staples (name, default_quantity, unit, category) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(name) DO UPDATE SET " + "default_quantity=excluded.default_quantity, " + "unit=excluded.unit, category=excluded.category", + (name, default_quantity, unit, category), + ) + conn.commit() + return cur.lastrowid + + +def remove_staple(conn, name: str) -> None: + conn.execute("DELETE FROM staples WHERE name = ? COLLATE NOCASE", (name,)) + conn.commit() + + +def list_staples(conn) -> list[sqlite3.Row]: + return conn.execute("SELECT * FROM staples ORDER BY category, name").fetchall() + + +# --- grocery list --- + +def add_grocery_item(conn, name, quantity, unit, category, source) -> int: + cur = conn.execute( + "INSERT INTO grocery_list_items (name, quantity, unit, category, source) " + "VALUES (?, ?, ?, ?, ?)", + (name, quantity, unit, category, source), + ) + conn.commit() + return cur.lastrowid + + +def list_grocery_items(conn) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM grocery_list_items ORDER BY category, name" + ).fetchall() + + +def clear_grocery_list(conn) -> None: + conn.execute("DELETE FROM grocery_list_items") + conn.commit() diff --git a/models.py b/models.py @@ -0,0 +1,56 @@ +"""SQLite connection handling and schema definition.""" +import os +import sqlite3 + +DB_PATH = os.environ.get("ANTON_DB_PATH", "anton.db") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + servings INTEGER, + instructions TEXT, + source TEXT, + is_keto INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS ingredients ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + quantity TEXT, + unit TEXT, + category TEXT +); + +CREATE TABLE IF NOT EXISTS staples ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + default_quantity TEXT, + unit TEXT, + category TEXT +); + +CREATE TABLE IF NOT EXISTS grocery_list_items ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + quantity TEXT, + unit TEXT, + category TEXT, + source TEXT NOT NULL CHECK (source IN ('staple', 'recipe', 'freeform')) +); +""" + + +def get_connection(db_path: str = DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + # SQLite parses the "REFERENCES ... ON DELETE CASCADE" in the schema but + # does NOT enforce foreign keys by default -- must opt in per connection. + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript(SCHEMA) + conn.commit() diff --git a/parsing.py b/parsing.py @@ -0,0 +1,173 @@ +"""Quantity parsing, ingredient merging, and /addrecipe text parsing.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from fractions import Fraction + +# Rough keyword heuristic for keto auto-suggestion -- there's no nutrition +# database backing this, it's just a carb-keyword check. Always manually +# overridable via the "Keto:" field in /addrecipe. +HIGH_CARB_KEYWORDS = { + "sugar", "flour", "rice", "pasta", "bread", "potato", "corn", "oats", + "honey", "syrup", +} + + +def parse_quantity(raw: str | None) -> Fraction | None: + """Parse '1', '1/2', or '1 1/2' into a Fraction. + + Returns None for anything that isn't a plain number/fraction (e.g. + "a pinch", "to taste") -- callers must treat those as non-mergeable + rather than guessing a value. + """ + if not raw: + return None + raw = raw.strip() + if not raw: + return None + + total = Fraction(0) + matched_any = False + for part in raw.split(): + if re.fullmatch(r"\d+/\d+", part): + num, den = part.split("/") + total += Fraction(int(num), int(den)) + matched_any = True + elif re.fullmatch(r"\d+(\.\d+)?", part): + total += Fraction(part) + matched_any = True + else: + return None + return total if matched_any else None + + +def format_quantity(value: Fraction) -> str: + """Format a Fraction back to display form, e.g. Fraction(3, 2) -> '1 1/2'.""" + whole, rem = divmod(value.numerator, value.denominator) + if rem == 0: + return str(whole) + if whole == 0: + return f"{rem}/{value.denominator}" + return f"{whole} {rem}/{value.denominator}" + + +@dataclass +class Item: + name: str + quantity: str | None + unit: str | None + category: str | None + source: str # 'staple' | 'recipe' | 'freeform' + + +def merge_ingredients(items: list[Item]) -> list[Item]: + """Merge items by (normalized name, unit). + + Matching name+unit pairs have their quantities summed when both parse + as numbers. Mismatched units, or quantities that don't parse (e.g. "a + pinch"), are kept as separate line items rather than guessing a + conversion -- per CLAUDE.md's merge rules. + """ + merged: dict[tuple[str, str | None], Item] = {} + passthrough: list[Item] = [] + + for item in items: + key_name = item.name.strip().lower() + key_unit = (item.unit or "").strip().lower() or None + qty = parse_quantity(item.quantity) + + if qty is None: + passthrough.append(item) + continue + + key = (key_name, key_unit) + if key in merged: + existing = merged[key] + existing_qty = parse_quantity(existing.quantity) + merged[key] = Item( + name=existing.name, + quantity=format_quantity(existing_qty + qty), + unit=existing.unit, + category=existing.category, + source=existing.source, + ) + else: + merged[key] = item + + return list(merged.values()) + passthrough + + +def suggest_keto(ingredient_names: list[str]) -> bool: + """Flag False if any ingredient name contains a high-carb keyword.""" + for name in ingredient_names: + lname = name.lower() + if any(keyword in lname for keyword in HIGH_CARB_KEYWORDS): + return False + return True + + +def parse_recipe_text(text: str) -> dict: + """Parse the structured text format expected after /addrecipe: + + Name: <name> + Servings: <n> + Source: <text or url> + Keto: yes|no (optional -- omit to auto-suggest) + Ingredients: + <quantity> | <unit> | <name> | <category> + ... + Instructions: + <free text, one or more lines> + """ + fields = {"name": None, "servings": None, "source": None, "is_keto": None} + ingredients: list[dict] = [] + instructions_lines: list[str] = [] + section = None + + for line in text.splitlines(): + stripped = line.strip() + lower = stripped.lower() + + if lower.startswith("name:"): + fields["name"] = stripped.split(":", 1)[1].strip() + elif lower.startswith("servings:"): + value = stripped.split(":", 1)[1].strip() + fields["servings"] = int(value) if value.isdigit() else None + elif lower.startswith("source:"): + fields["source"] = stripped.split(":", 1)[1].strip() + elif lower.startswith("keto:"): + value = stripped.split(":", 1)[1].strip().lower() + fields["is_keto"] = value in ("yes", "true", "1") + elif lower.startswith("ingredients:"): + section = "ingredients" + elif lower.startswith("instructions:"): + section = "instructions" + elif section == "ingredients" and stripped: + parts = [p.strip() for p in stripped.split("|")] + parts += [None] * (4 - len(parts)) + quantity, unit, name, category = parts[:4] + ingredients.append({ + "quantity": quantity or None, + "unit": unit or None, + "name": name, + "category": category or None, + }) + elif section == "instructions" and stripped: + instructions_lines.append(stripped) + + if not fields["name"]: + raise ValueError("recipe text must include a 'Name:' line") + + is_keto = fields["is_keto"] + if is_keto is None: + is_keto = suggest_keto([i["name"] for i in ingredients if i["name"]]) + + return { + "name": fields["name"], + "servings": fields["servings"], + "source": fields["source"], + "is_keto": is_keto, + "ingredients": ingredients, + "instructions": "\n".join(instructions_lines), + } diff --git a/pytest.ini b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot>=22,<23 +python-dotenv +pytest diff --git a/tests/test_parsing.py b/tests/test_parsing.py @@ -0,0 +1,84 @@ +from fractions import Fraction + +from parsing import Item, merge_ingredients, parse_quantity, parse_recipe_text, suggest_keto + + +def test_parse_quantity_whole_number(): + assert parse_quantity("2") == Fraction(2) + + +def test_parse_quantity_fraction(): + assert parse_quantity("1/2") == Fraction(1, 2) + + +def test_parse_quantity_mixed_number(): + assert parse_quantity("1 1/2") == Fraction(3, 2) + + +def test_parse_quantity_unparseable_returns_none(): + assert parse_quantity("a pinch") is None + + +def test_merge_sums_matching_name_and_unit(): + items = [ + Item(name="Milk", quantity="1", unit="cup", category="dairy", source="staple"), + Item(name="milk", quantity="1", unit="cup", category="dairy", source="recipe"), + ] + merged = merge_ingredients(items) + assert len(merged) == 1 + assert merged[0].quantity == "2" + + +def test_merge_keeps_mismatched_units_separate(): + items = [ + Item(name="Flour", quantity="1", unit="cup", category="pantry", source="staple"), + Item(name="Flour", quantity="200", unit="g", category="pantry", source="recipe"), + ] + merged = merge_ingredients(items) + assert len(merged) == 2 + + +def test_merge_keeps_unparseable_quantities_separate(): + items = [ + Item(name="Salt", quantity="a pinch", unit=None, category="pantry", source="recipe"), + Item(name="Salt", quantity="a pinch", unit=None, category="pantry", source="recipe"), + ] + merged = merge_ingredients(items) + assert len(merged) == 2 + + +def test_parse_recipe_text_basic(): + text = ( + "Name: Test Chili\n" + "Servings: 4\n" + "Source: test\n" + "Keto: yes\n" + "Ingredients:\n" + "1 | lb | ground beef | meat\n" + "1 | cup | diced tomatoes | pantry\n" + "Instructions:\n" + "Brown the beef.\n" + "Simmer.\n" + ) + parsed = parse_recipe_text(text) + assert parsed["name"] == "Test Chili" + assert parsed["servings"] == 4 + assert parsed["is_keto"] is True + assert len(parsed["ingredients"]) == 2 + assert "Brown the beef." in parsed["instructions"] + + +def test_parse_recipe_text_missing_name_raises(): + try: + parse_recipe_text("Servings: 2\n") + assert False, "expected ValueError" + except ValueError: + pass + + +def test_suggest_keto_flags_high_carb_ingredient(): + assert suggest_keto(["ground beef", "flour"]) is False + + +def test_suggest_keto_true_when_no_high_carb_keywords(): + assert suggest_keto(["ground beef", "cheese"]) is True