πŸ“„ test_recipe_toggle_integration.py 33,903 bytes Apr 27, 2026 πŸ“‹ Raw

"""Integration tests for Recipe Toggle System β€” Backend Validation.

Tests:
1. Recipe Extraction (URL fetch + structured JSON)
2. Temp State (SQLite CRUD, 5-min TTL, JSON serialization)
3. Callback Handler (toggle, commit, cancel)
4. Telegram Keyboard Builder (InlineKeyboardMarkup format)
"""

import json
import os
import sqlite3
import sys
import tempfile
import time
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

Ensure icarus is importable

sys.path.insert(0, str(Path(file).resolve().parent.parent.parent))

from icarus.core.db.grocery_list import (
DB_PATH,
SCHEMA,
add_items,
clear_list,
delete_temp_state,
get_temp_state,
init_db,
list_items,
save_temp_state,
update_temp_state,
update_status,
)
from icarus.core.handlers.recipe_toggle import (
build_caption,
build_toggle_keyboard,
)

---------------------------------------------------------------------------

Fixtures

---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def temp_db(tmp_path, monkeypatch):
"""Use a temporary database for all tests."""
db_file = tmp_path / "test_icarus.db"
monkeypatch.setattr("icarus.core.db.grocery_list.DB_PATH", db_file)
# Re-init with temp path
import icarus.core.db.grocery_list as db_mod
old_conn = db_mod._get_connection
def _new_conn():
conn = sqlite3.connect(str(db_file), check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
monkeypatch.setattr(db_mod, "_get_connection", _new_conn)
init_db()
yield db_file

SAMPLE_RECIPE_URL = "https://www.allrecipes.com/recipe/158968/spinach-and-strawberry-salad/"

SAMPLE_INGREDIENTS = [
"6 cups fresh spinach",
"1 pint strawberries, sliced",
"1/2 cup sliced almonds",
"1/4 cup red onion, sliced",
"1/2 cup poppy seed dressing",
]

SAMPLE_INGREDIENTS_STATE = [
{"index": 0, "text": "6 cups fresh spinach", "selected": True, "recipe_id": "spinach-strawberry-salad"},
{"index": 1, "text": "1 pint strawberries, sliced", "selected": True, "recipe_id": "spinach-strawberry-salad"},
{"index": 2, "text": "1/2 cup sliced almonds", "selected": True, "recipe_id": "spinach-strawberry-salad"},
{"index": 3, "text": "1/4 cup red onion, sliced", "selected": True, "recipe_id": "spinach-strawberry-salad"},
{"index": 4, "text": "1/2 cup poppy seed dressing", "selected": True, "recipe_id": "spinach-strawberry-salad"},
]

===========================================================================

TEST 1: Recipe Extraction

===========================================================================

class TestRecipeExtraction:
"""Test recipe extraction module (unit-level, no network)."""

def test_extract_ld_json_from_html(self):
    """Verify ld+json parsing from HTML content."""
    from icarus.core.extractors.recipe import _extract_ld_json

    html = """
    <html><head>
    <script type="application/ld+json">
    {
        "@context": "https://schema.org",
        "@type": "Recipe",
        "name": "Spinach Strawberry Salad",
        "recipeYield": "6",
        "prepTime": "PT15M",
        "cookTime": null,
        "totalTime": "PT15M",
        "recipeIngredient": [
            "6 cups fresh spinach",
            "1 pint strawberries, sliced",
            "1/2 cup sliced almonds"
        ],
        "recipeInstructions": [
            {"@type": "HowToStep", "text": "Combine spinach and strawberries."},
            {"@type": "HowToStep", "text": "Top with almonds and dressing."}
        ],
        "recipeCategory": "Salad",
        "keywords": "spinach, strawberry, salad, easy"
    }
    </script>
    </head><body></body></html>
    """

    result = _extract_ld_json(html)
    assert result is not None, "Should parse ld+json from HTML"
    assert result["@type"] == "Recipe"
    assert result["name"] == "Spinach Strawberry Salad"
    assert len(result["recipeIngredient"]) == 3
    assert "spinach" in result["recipeIngredient"][0].lower()

def test_extract_ld_json_graph_format(self):
    """Verify ld+json extraction from @graph structure (common on recipe sites)."""
    from icarus.core.extractors.recipe import _extract_ld_json

    html = """
    <html><head>
    <script type="application/ld+json">
    {
        "@context": "https://schema.org",
        "@graph": [
            {"@type": "Organization", "name": "Allrecipes"},
            {
                "@type": "Recipe",
                "name": "Test Recipe",
                "recipeIngredient": ["eggs", "flour"],
                "recipeInstructions": [{"@type": "HowToStep", "text": "Mix and bake."}]
            }
        ]
    }
    </script>
    </head></html>
    """

    result = _extract_ld_json(html)
    assert result is not None
    assert result["@type"] == "Recipe"
    assert result["name"] == "Test Recipe"

def test_parse_servings(self):
    """Test servings parsing from various formats."""
    from icarus.core.extractors.recipe import _parse_servings

    assert _parse_servings("6") == "6"
    assert _parse_servings("Serves 4-6") == "4-6"
    assert _parse_servings("8 servings") == "8"
    assert _parse_servings(None) is None
    assert _parse_servings("") is None

def test_parse_duration(self):
    """Test ISO 8601 duration parsing."""
    from icarus.core.extractors.recipe import _parse_duration

    assert _parse_duration("PT15M") == "15 min"
    assert _parse_duration("PT1H30M") == "1 hr 30 min"
    assert _parse_duration("PT2H") == "2 hr"
    assert _parse_duration(None) is None

def test_clean_ingredients(self):
    """Test ingredient cleaning."""
    from icarus.core.extractors.recipe import _clean_ingredients

    raw = ["  6 cups fresh spinach  ", "1 pint\nstrawberries", "", None]
    result = _clean_ingredients(raw)
    assert len(result) == 2  # Empty and None filtered
    assert "6 cups fresh spinach" in result[0]
    assert "strawberries" in result[1]

def test_parse_instructions(self):
    """Test instruction parsing from various formats."""
    from icarus.core.extractors.recipe import _parse_instructions

    # HowToStep format
    steps = [
        {"@type": "HowToStep", "text": "Combine ingredients."},
        {"@type": "HowToStep", "text": "Bake for 30 min."},
    ]
    result = _parse_instructions(steps)
    assert len(result) == 2
    assert result[0] == "Combine ingredients."

    # Plain string
    result = _parse_instructions("Step one\nStep two")
    assert len(result) >= 2

def test_extract_tags(self):
    """Test tag extraction from recipe metadata."""
    from icarus.core.extractors.recipe import _extract_tags

    recipe = {
        "name": "Chicken Stir Fry",
        "recipeCategory": "Dinner",
        "recipeCuisine": "Asian",
        "keywords": "chicken, stir fry, easy, 30-minute",
    }
    tags = _extract_tags(recipe)
    assert "Dinner" in tags or "dinner" in " ".join(tags).lower()
    assert len(tags) <= 8

def test_slugify(self):
    """Test recipe ID generation."""
    from icarus.core.extractors.recipe import _slugify

    assert _slugify("Spinach & Strawberry Salad") == "spinach-strawberry-salad"
    assert _slugify("Mom's Best Chili!!!") == "mom-s-best-chili"

def test_fix_json_newlines(self):
    """Test JSON newline fixing for recipe site ld+json."""
    from icarus.core.extractors.recipe import _fix_json_newlines

    # Newlines inside strings should be escaped
    broken = '"text": "Step 1:\nMix ingredients"'
    fixed = _fix_json_newlines(broken)
    assert "\\n" in fixed
    assert "\n" not in fixed.replace("\\n", "")  # No raw newlines remain in strings

def test_build_text_from_html(self):
    """Test HTML β†’ clean text conversion."""
    from icarus.core.extractors.recipe import _build_text_from_html

    html = """
    <html><body>
    <nav>Navigation junk</nav>
    <div class="recipe-content">
        <h2>Ingredients</h2>
        <p>6 cups fresh spinach</p>
        <p>1 pint strawberries</p>
    </div>
    <footer>Footer junk</footer>
    </body></html>
    """
    text = _build_text_from_html(html)
    assert "spinach" in text.lower()
    assert "footer" not in text.lower()
    assert len(text) > 0

def test_format_recipe(self):
    """Test recipe formatting for Telegram."""
    from icarus.core.extractors.recipe import format_recipe

    recipe = {
        "title": "Test Salad",
        "servings": "6",
        "prep_time": "15 min",
        "ingredients": ["spinach", "strawberries", "almonds"],
        "instructions": ["Combine ingredients.", "Serve immediately."],
        "tags": ["salad", "easy"],
        "source_url": "https://example.com/recipe",
    }
    result = format_recipe(recipe)
    assert "Test Salad" in result
    assert "spinach" in result
    assert "Combine ingredients" in result

===========================================================================

TEST 2: Temp State (SQLite CRUD + TTL)

===========================================================================

class TestTempState:
"""Test recipe_temp_state table: CRUD, TTL, JSON serialization."""

def test_init_db_creates_tables(self, temp_db):
    """Verify schema creates both required tables."""
    conn = sqlite3.connect(str(temp_db))
    conn.row_factory = sqlite3.Row

    # Check grocery_list table
    tables = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table'"
    ).fetchall()
    table_names = {t["name"] for t in tables}
    assert "grocery_list" in table_names
    assert "recipe_temp_state" in table_names

    # Check indexes
    indexes = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='index'"
    ).fetchall()
    index_names = {i["name"] for i in indexes}
    assert "idx_grocery_status" in index_names
    assert "idx_temp_expires" in index_names

    conn.close()

def test_save_and_get_temp_state(self):
    """Test saving and retrieving temp state."""
    save_temp_state(
        recipe_id="spinach-strawberry-salad",
        user_id=8386527252,
        chat_id=8386527252,
        message_id=42,
        title="Spinach Strawberry Salad",
        source_url=SAMPLE_RECIPE_URL,
        ingredients=SAMPLE_INGREDIENTS_STATE,
    )

    state = get_temp_state("spinach-strawberry-salad")
    assert state is not None
    assert state["recipe_id"] == "spinach-strawberry-salad"
    assert state["title"] == "Spinach Strawberry Salad"
    assert state["user_id"] == 8386527252
    assert state["chat_id"] == 8386527252
    assert len(state["ingredients"]) == 5
    assert state["ingredients"][0]["text"] == "6 cups fresh spinach"
    assert state["ingredients"][0]["selected"] is True

def test_temp_state_json_serialization(self):
    """Verify ingredients_json serializes/deserializes correctly."""
    ingredients = [
        {"index": 0, "text": "1/2 cup olive oil", "selected": True, "recipe_id": "test"},
        {"index": 1, "text": "2 cloves garlic, minced", "selected": False, "recipe_id": "test"},
    ]

    save_temp_state(
        recipe_id="json-test",
        user_id=123,
        chat_id=456,
        message_id=789,
        title="JSON Test",
        source_url="https://example.com",
        ingredients=ingredients,
    )

    state = get_temp_state("json-test")
    assert state is not None
    assert len(state["ingredients"]) == 2
    # Verify special characters preserved
    assert state["ingredients"][0]["text"] == "1/2 cup olive oil"
    assert state["ingredients"][1]["text"] == "2 cloves garlic, minced"
    assert state["ingredients"][1]["selected"] is False

def test_temp_state_ttl_expiry(self, temp_db):
    """Verify that expired temp state entries are cleaned up."""
    save_temp_state(
        recipe_id="ttl-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="TTL Test",
        source_url="https://example.com",
        ingredients=[{"index": 0, "text": "test item", "selected": True, "recipe_id": "ttl-test"}],
    )

    # Verify it's there
    state = get_temp_state("ttl-test")
    assert state is not None

    # Manually expire it by setting expires_at to the past
    conn = sqlite3.connect(str(temp_db))
    conn.execute(
        "UPDATE recipe_temp_state SET expires_at = datetime('now', '-1 minute') WHERE recipe_id = ?",
        ("ttl-test",),
    )
    conn.commit()
    conn.close()

    # Now get_temp_state should clean it up and return None
    state = get_temp_state("ttl-test")
    assert state is None, "Expired temp state should be cleaned up"

def test_temp_state_5_min_ttl(self):
    """Verify TTL is 5 minutes from creation."""
    # Save state, then verify via get_temp_state that it's not expired yet.
    # Then check the expires_at field directly via module connection.
    before = datetime.now()
    save_temp_state(
        recipe_id="5min-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="5 Min Test",
        source_url="https://example.com",
        ingredients=[{"index": 0, "text": "test", "selected": True, "recipe_id": "5min-test"}],
    )
    after = datetime.now()

    # State should still be valid (not expired)
    state = get_temp_state("5min-test")
    assert state is not None, "State should exist immediately after creation"

    # Verify TTL duration by reading expires_at directly via module connection
    import icarus.core.db.grocery_list as db_mod
    conn = db_mod._get_connection()
    try:
        row = conn.execute(
            "SELECT expires_at FROM recipe_temp_state WHERE recipe_id = ?",
            ("5min-test",),
        ).fetchone()
        from datetime import timezone
        expires_at = datetime.fromisoformat(dict(row)["expires_at"])
        expected_min = before + timedelta(minutes=5)
        expected_max = after + timedelta(minutes=5)
        assert expected_min <= expires_at <= expected_max, \
            f"Expected TTL ~5min, got expires_at={expires_at}"
    finally:
        conn.close()

def test_update_temp_state(self):
    """Test updating ingredients in temp state (toggle operation)."""
    save_temp_state(
        recipe_id="toggle-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Toggle Test",
        source_url="https://example.com",
        ingredients=SAMPLE_INGREDIENTS_STATE,
    )

    # Toggle ingredient #2 (strawberries)
    toggled = SAMPLE_INGREDIENTS_STATE.copy()
    toggled[1] = {**toggled[1], "selected": False}

    update_temp_state("toggle-test", toggled)

    state = get_temp_state("toggle-test")
    assert state is not None
    assert state["ingredients"][1]["selected"] is False
    assert state["ingredients"][0]["selected"] is True  # Others unchanged

def test_delete_temp_state(self):
    """Test deleting temp state."""
    save_temp_state(
        recipe_id="delete-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Delete Test",
        source_url="https://example.com",
        ingredients=[{"index": 0, "text": "test", "selected": True, "recipe_id": "delete-test"}],
    )

    assert get_temp_state("delete-test") is not None
    delete_temp_state("delete-test")
    assert get_temp_state("delete-test") is None

def test_get_nonexistent_temp_state(self):
    """Test getting a non-existent recipe state returns None."""
    state = get_temp_state("does-not-exist")
    assert state is None

===========================================================================

TEST 3: Callback Handler Logic (toggle, commit, cancel)

===========================================================================

class TestCallbackHandlers:
"""Test callback handler logic (toggle, commit, cancel)."""

def test_toggle_flips_selected(self):
    """Simulate toggle callback: ingredient #0 toggles from selected→deselected."""
    save_temp_state(
        recipe_id="toggle-cb-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Toggle CB Test",
        source_url=SAMPLE_RECIPE_URL,
        ingredients=SAMPLE_INGREDIENTS_STATE,
    )

    # Simulate toggle of ingredient #0
    state = get_temp_state("toggle-cb-test")
    assert state is not None
    ingredients = state["ingredients"]

    # Toggle ingredient 0
    ingredients[0]["selected"] = not ingredients[0]["selected"]
    update_temp_state("toggle-cb-test", ingredients)

    # Verify
    state = get_temp_state("toggle-cb-test")
    assert state["ingredients"][0]["selected"] is False  # Was True
    assert state["ingredients"][1]["selected"] is True   # Unchanged

def test_toggle_callback_data_format(self):
    """Verify callback data parsing for toggle:{recipe_id}:{index}."""
    callback_data = "toggle:spinach-strawberry-salad:0"
    parts = callback_data.split(":")
    assert parts[0] == "toggle"
    assert parts[1] == "spinach-strawberry-salad"
    assert int(parts[2]) == 0

    callback_data = "toggle:spinach-strawberry-salad:4"
    parts = callback_data.split(":")
    assert int(parts[2]) == 4

def test_commit_callback_data_format(self):
    """Verify callback data parsing for commit:{recipe_id}."""
    callback_data = "commit:spinach-strawberry-salad"
    parts = callback_data.split(":")
    assert parts[0] == "commit"
    assert parts[1] == "spinach-strawberry-salad"

def test_cancel_callback_data_format(self):
    """Verify callback data parsing for cancel:{recipe_id}."""
    callback_data = "cancel:spinach-strawberry-salad"
    parts = callback_data.split(":")
    assert parts[0] == "cancel"
    assert parts[1] == "spinach-strawberry-salad"

def test_commit_adds_selected_to_grocery_list(self):
    """Simulate commit: selected ingredients get added to grocery_list table."""
    # Create temp state with some deselected
    ingredients = SAMPLE_INGREDIENTS_STATE.copy()
    ingredients[2] = {**ingredients[2], "selected": False}  # Deselect almonds
    ingredients[4] = {**ingredients[4], "selected": False}  # Deselect dressing

    save_temp_state(
        recipe_id="commit-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Commit Test",
        source_url=SAMPLE_RECIPE_URL,
        ingredients=ingredients,
    )

    state = get_temp_state("commit-test")
    selected = [i for i in state["ingredients"] if i["selected"]]

    # Should be 3 selected (indices 0, 1, 3)
    assert len(selected) == 3
    assert selected[0]["text"] == "6 cups fresh spinach"
    assert selected[1]["text"] == "1 pint strawberries, sliced"
    assert selected[2]["text"] == "1/4 cup red onion, sliced"

    # Add to grocery list (simulating commit handler logic)
    count = add_items(
        items=[{"text": i["text"]} for i in selected],
        recipe_source="Commit Test",
        recipe_url=SAMPLE_RECIPE_URL,
        requested_by="matt",
    )
    assert count == 3

    # Verify grocery_list has the items
    items = list_items()
    assert len(items) == 3
    recipe_items = [i for i in items if i["recipe_source"] == "Commit Test"]
    assert len(recipe_items) == 3

    # Verify deselected items are NOT in the list
    item_names = [i["item"] for i in items]
    assert "6 cups fresh spinach" in item_names
    assert "1/2 cup sliced almonds" not in item_names
    assert "1/2 cup poppy seed dressing" not in item_names

    # Clean up
    delete_temp_state("commit-test")

def test_cancel_deletes_temp_state(self):
    """Simulate cancel: temp state is deleted, no grocery items added."""
    save_temp_state(
        recipe_id="cancel-test",
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Cancel Test",
        source_url=SAMPLE_RECIPE_URL,
        ingredients=SAMPLE_INGREDIENTS_STATE,
    )

    # Cancel = delete temp state
    delete_temp_state("cancel-test")
    assert get_temp_state("cancel-test") is None

    # Verify no items in grocery list from this recipe
    items = list_items(requested_by="matt")
    cancel_items = [i for i in items if i["recipe_source"] == "Cancel Test"]
    assert len(cancel_items) == 0

def test_multiple_toggles_then_commit(self):
    """Simulate a realistic flow: toggle multiple ingredients, then commit."""
    recipe_id = "multi-toggle-test"

    # Initial state: all selected
    save_temp_state(
        recipe_id=recipe_id,
        user_id=111,
        chat_id=222,
        message_id=333,
        title="Multi Toggle Test",
        source_url=SAMPLE_RECIPE_URL,
        ingredients=SAMPLE_INGREDIENTS_STATE,
    )

    # Toggle ingredient 0 OFF
    state = get_temp_state(recipe_id)
    ingredients = state["ingredients"]
    ingredients[0]["selected"] = False
    update_temp_state(recipe_id, ingredients)

    # Toggle ingredient 2 OFF
    state = get_temp_state(recipe_id)
    ingredients = state["ingredients"]
    ingredients[2]["selected"] = False
    update_temp_state(recipe_id, ingredients)

    # Toggle ingredient 0 back ON (change mind)
    state = get_temp_state(recipe_id)
    ingredients = state["ingredients"]
    ingredients[0]["selected"] = True
    update_temp_state(recipe_id, ingredients)

    # Verify final state
    state = get_temp_state(recipe_id)
    assert state["ingredients"][0]["selected"] is True   # Toggled back on
    assert state["ingredients"][1]["selected"] is True   # Never changed
    assert state["ingredients"][2]["selected"] is False  # Toggled off
    assert state["ingredients"][3]["selected"] is True   # Never changed
    assert state["ingredients"][4]["selected"] is True   # Never changed

    # Commit selected
    selected = [i for i in state["ingredients"] if i["selected"]]
    assert len(selected) == 4

    count = add_items(
        items=[{"text": i["text"]} for i in selected],
        recipe_source="Multi Toggle Test",
        recipe_url=SAMPLE_RECIPE_URL,
        requested_by="matt",
    )
    assert count == 4

    # Cleanup
    delete_temp_state(recipe_id)

===========================================================================

TEST 4: Telegram Keyboard Builder

===========================================================================

class TestToggleKeyboard:
"""Test build_toggle_keyboard and caption builder."""

def test_keyboard_structure_all_selected(self):
    """Test keyboard with all ingredients selected."""
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5)

    assert "inline_keyboard" in keyboard
    kb = keyboard["inline_keyboard"]

    # Should have ingredient buttons (5 items in rows of 4 = 2 rows)
    # Row 1: buttons 0-3, Row 2: button 4 + commit/cancel
    ingredient_rows = [row for row in kb if any("toggle" in btn.get("callback_data", "") for btn in row)]
    assert len(ingredient_rows) == 2  # 4+1

    # First row: 4 ingredient buttons
    assert len(ingredient_rows[0]) == 4
    # Second row: 1 ingredient button
    assert len(ingredient_rows[1]) == 1

    # Last row: commit + cancel
    action_row = kb[-1]
    assert len(action_row) == 2
    assert action_row[0]["callback_data"].startswith("commit:")
    assert action_row[1]["callback_data"].startswith("cancel:")

def test_keyboard_callback_data_format(self):
    """Verify callback data matches spec: toggle:{recipe_id}:{index}."""
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5)
    kb = keyboard["inline_keyboard"]

    # Check first ingredient button
    first_btn = kb[0][0]
    assert first_btn["callback_data"] == "toggle:spinach-strawberry-salad:0"
    assert "βœ…" in first_btn["text"] or "❌" in first_btn["text"]

    # Check commit button
    action_row = kb[-1]
    assert action_row[0]["callback_data"] == "commit:spinach-strawberry-salad"
    assert "Commit" in action_row[0]["text"]

def test_keyboard_button_labels(self):
    """Test button labels show βœ…/❌ based on selection state."""
    # All selected
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5)
    kb = keyboard["inline_keyboard"]

    first_btn = kb[0][0]
    assert "βœ…" in first_btn["text"]  # Selected

    # Deselect first ingredient
    deselected = SAMPLE_INGREDIENTS_STATE.copy()
    deselected[0] = {**deselected[0], "selected": False}
    keyboard = build_toggle_keyboard(deselected, 4)
    kb = keyboard["inline_keyboard"]

    first_btn = kb[0][0]
    assert "❌" in first_btn["text"]  # Deselected

def test_keyboard_commit_button_shows_count(self):
    """Test commit button shows selected count."""
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5)
    action_row = keyboard["inline_keyboard"][-1]
    assert "5" in action_row[0]["text"]  # "βœ… Commit (5)"

    # With fewer selected
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 3)
    action_row = keyboard["inline_keyboard"][-1]
    assert "3" in action_row[0]["text"]

def test_keyboard_zero_selected(self):
    """Test keyboard when no items are selected."""
    deselected = [
        {**ing, "selected": False}
        for ing in SAMPLE_INGREDIENTS_STATE
    ]
    keyboard = build_toggle_keyboard(deselected, 0)
    action_row = keyboard["inline_keyboard"][-1]
    assert "Select at least 1" in action_row[0]["text"]

def test_keyboard_4_per_row_layout(self):
    """Test that ingredient buttons are laid out 4 per row."""
    # 5 ingredients β†’ rows of [4, 1]
    keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5)
    kb = keyboard["inline_keyboard"]

    # First row should have 4 buttons
    assert len(kb[0]) == 4

    # With 8 ingredients β†’ rows of [4, 4]
    ingredients_8 = [
        {"index": i, "text": f"Item {i}", "selected": True, "recipe_id": "test"}
        for i in range(8)
    ]
    keyboard = build_toggle_keyboard(ingredients_8, 8)
    kb = keyboard["inline_keyboard"]
    assert len(kb[0]) == 4
    assert len(kb[1]) == 4

    # With 9 ingredients β†’ rows of [4, 4, 1]
    ingredients_9 = [
        {"index": i, "text": f"Item {i}", "selected": True, "recipe_id": "test"}
        for i in range(9)
    ]
    keyboard = build_toggle_keyboard(ingredients_9, 9)
    kb = keyboard["inline_keyboard"]
    assert len(kb[0]) == 4
    assert len(kb[1]) == 4
    assert len(kb[2]) == 1

def test_caption_all_selected(self):
    """Test caption with all ingredients selected."""
    caption = build_caption(
        "Spinach Strawberry Salad",
        "www.allrecipes.com",
        SAMPLE_INGREDIENTS_STATE,
    )
    # Title and domain should be present (may be MarkdownV2-escaped)
    assert "Spinach Strawberry Salad" in caption
    # Domain dots are escaped in MarkdownV2 (www\.allrecipes\.com)
    assert "allrecipes.com" in caption.replace("\\", "") or "allrecipes.com" in caption
    assert "βœ…" in caption
    # All items should have βœ…
    for i, ing in enumerate(SAMPLE_INGREDIENTS_STATE):
        assert f"#{i+1}" in caption

def test_caption_with_deselected(self):
    """Test caption with some ingredients deselected."""
    deselected = SAMPLE_INGREDIENTS_STATE.copy()
    deselected[2] = {**deselected[2], "selected": False}

    caption = build_caption(
        "Spinach Strawberry Salad",
        "www.allrecipes.com",
        deselected,
    )
    # Should have βœ… for selected and ❌ for deselected
    assert "❌" in caption
    # Deselected item should have strikethrough in MarkdownV2
    assert "~" in caption  # Strikethrough markers

===========================================================================

TEST 5: Grocery List CRUD

===========================================================================

class TestGroceryList:
"""Test grocery_list table CRUD operations."""

def test_add_and_list_items(self):
    """Test adding and listing grocery items."""
    items = [
        {"text": "6 cups fresh spinach"},
        {"text": "1 pint strawberries, sliced"},
        {"text": "1/2 cup sliced almonds"},
    ]
    count = add_items(items, "Test Recipe", "https://example.com", "matt")
    assert count == 3

    listed = list_items()
    assert len(listed) == 3
    assert listed[0]["item"] == "6 cups fresh spinach"
    assert listed[0]["recipe_source"] == "Test Recipe"
    assert listed[0]["requested_by"] == "matt"
    assert listed[0]["status"] == "pending"

def test_add_items_with_quantity(self):
    """Test adding items with quantity field."""
    items = [
        {"text": "2 lbs chicken breast", "quantity": "2 lbs"},
        {"text": "1 cup rice", "quantity": "1 cup"},
    ]
    count = add_items(items, "Chicken Rice", "https://example.com", "matt")
    assert count == 2

    listed = list_items()
    rice_item = next(i for i in listed if "rice" in i["item"].lower())
    assert rice_item["quantity"] == "1 cup"

def test_filter_by_status(self):
    """Test listing items filtered by status."""
    items = [{"text": "milk"}, {"text": "eggs"}, {"text": "bread"}]
    add_items(items, "Test", "https://example.com", "matt")

    # All should be pending
    pending = list_items(status="pending")
    assert len(pending) == 3

    # Update one to in_cart
    first_id = pending[0]["id"]
    result = update_status(first_id, "in_cart")
    assert result is True

    in_cart = list_items(status="in_cart")
    assert len(in_cart) == 1
    assert in_cart[0]["item"] == "milk"

def test_filter_by_user(self):
    """Test listing items filtered by user."""
    add_items([{"text": "milk"}], "Test", "https://example.com", "matt")
    add_items([{"text": "bread"}], "Test", "https://example.com", "aundrea")

    matt_items = list_items(requested_by="matt")
    assert len(matt_items) == 1
    assert matt_items[0]["item"] == "milk"

def test_update_status_invalid(self):
    """Test that invalid status is rejected."""
    result = update_status(1, "invalid_status")
    assert result is False

def test_clear_list(self):
    """Test clearing the grocery list."""
    add_items([{"text": "milk"}], "Test", "https://example.com", "matt")
    add_items([{"text": "eggs"}], "Test", "https://example.com", "aundrea")

    # Clear all
    removed = clear_list()
    assert removed >= 2

    # Verify empty
    assert len(list_items()) == 0

def test_clear_list_by_user(self):
    """Test clearing items for a specific user."""
    add_items([{"text": "milk"}], "Test", "https://example.com", "matt")
    add_items([{"text": "eggs"}], "Test", "https://example.com", "aundrea")

    removed = clear_list(requested_by="matt")
    assert removed == 1

    # Only aundrea's items remain
    remaining = list_items()
    assert len(remaining) == 1
    assert remaining[0]["requested_by"] == "aundrea"

def test_normalized_item_dedup(self):
    """Test that normalized items work for deduplication."""
    # The _normalize_item function strips quantities for deduplication
    # This tests the normalization, not auto-dedup (which isn't implemented)
    from icarus.core.db.grocery_list import _normalize_item

    assert _normalize_item("2 cups flour") == "flour"
    assert _normalize_item("1 lb chicken thighs") == "chicken thighs"
    assert _normalize_item("fresh spinach") == "fresh spinach"

===========================================================================

RUN AS SCRIPT

===========================================================================

if name == "main":
pytest.main([file, "-v", "--tb=short"])