kiwi/tests/services/recipe/test_recipe_engine.py
pyr0ball c3e7dc1ea4 feat: time-first recipe entry (kiwi#52)
- Add max_total_min to RecipeRequest schema and TypeScript interface
- Add _within_time() helper to recipe_engine using parse_time_effort()
  with graceful degradation (empty directions or no signals -> pass)
- Wire max_total_min filter into suggest() loop after max_time_min
- Add time_first_layout to allowed settings keys
- Add timeFirstLayout ref to settings store (preserves sensoryPreferences)
- Add maxTotalMin ref to recipes store, wired into _buildRequest()
- Add time bucket selector UI (15/30/45/60/90 min) in RecipesView
  Find tab, gated by timeFirstLayout != 'normal'
- Add time-first layout selector section in SettingsView
- Add 5 _within_time unit tests and 2 settings key tests
2026-04-24 10:15:58 -07:00

172 lines
6.8 KiB
Python

import pytest, json
from tests.services.recipe.test_element_classifier import store_with_profiles
from tests.db.test_store_recipes import store_with_recipes
def test_level1_returns_ranked_suggestions(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(
pantry_items=["butter", "parmesan"],
level=1,
constraints=[],
)
result = engine.suggest(req)
assert len(result.suggestions) > 0
assert result.suggestions[0].title == "Butter Pasta"
def test_level1_expiry_first_requires_rate_limit_free(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
engine = RecipeEngine(store_with_recipes)
for _ in range(5):
req = RecipeRequest(
pantry_items=["butter"],
level=1,
constraints=[],
expiry_first=True,
tier="free",
)
result = engine.suggest(req)
assert result.rate_limited is False
req = RecipeRequest(
pantry_items=["butter"],
level=1,
constraints=[],
expiry_first=True,
tier="free",
)
result = engine.suggest(req)
assert result.rate_limited is True
def test_level2_returns_swap_candidates(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
store_with_recipes.conn.execute("""
INSERT INTO substitution_pairs
(original_name, substitute_name, constraint_label, fat_delta, occurrence_count)
VALUES (?,?,?,?,?)
""", ("butter", "coconut oil", "vegan", -1.0, 12))
store_with_recipes.conn.commit()
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(
pantry_items=["butter", "parmesan"],
level=2,
constraints=["vegan"],
)
result = engine.suggest(req)
swapped = [s for s in result.suggestions if s.swap_candidates]
assert len(swapped) > 0
def test_element_gaps_reported(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(pantry_items=["butter"], level=1, constraints=[])
result = engine.suggest(req)
assert isinstance(result.element_gaps, list)
def test_grocery_list_max_missing(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
engine = RecipeEngine(store_with_recipes)
# Butter Pasta needs butter, pasta, parmesan. We have only butter → missing 2
req = RecipeRequest(
pantry_items=["butter"],
level=1,
constraints=[],
max_missing=2,
)
result = engine.suggest(req)
assert all(len(s.missing_ingredients) <= 2 for s in result.suggestions)
assert isinstance(result.grocery_list, list)
def test_hard_day_mode_filters_complex_methods(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest, _classify_method_complexity
# Test the classifier directly
assert _classify_method_complexity(["mix all ingredients", "stir to combine"]) == "easy"
assert _classify_method_complexity(["sauté onions", "braise for 2 hours"]) == "involved"
# With hard_day_mode, involved recipes should be filtered out
# Seed a hard recipe into the store
store_with_recipes.conn.execute("""
INSERT INTO recipes (external_id, title, ingredients, ingredient_names,
directions, category, keywords, element_coverage)
VALUES (?,?,?,?,?,?,?,?)
""", ("99", "Braised Short Ribs",
'["butter","beef ribs"]', '["butter","beef ribs"]',
'["braise short ribs for 3 hours","reduce sauce"]',
"Meat", '[]', '{"Richness":0.8}'))
store_with_recipes.conn.commit()
engine = RecipeEngine(store_with_recipes)
req_hard = RecipeRequest(pantry_items=["butter"], level=1, constraints=[], hard_day_mode=True)
result = engine.suggest(req_hard)
titles = [s.title for s in result.suggestions]
assert "Braised Short Ribs" not in titles
def test_grocery_links_free_tier(store_with_recipes):
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(pantry_items=["butter"], level=1, constraints=[], max_missing=5)
result = engine.suggest(req)
# Links may be empty if no retailer env vars set, but structure must be correct
assert isinstance(result.grocery_links, list)
for link in result.grocery_links:
assert hasattr(link, "ingredient")
assert hasattr(link, "retailer")
assert hasattr(link, "url")
def test_suggest_returns_no_assembly_results(store_with_recipes):
"""Assembly templates (negative IDs) must no longer appear in suggest() output."""
from app.services.recipe.recipe_engine import RecipeEngine
from app.models.schemas.recipe import RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(
pantry_items=["flour tortilla", "chicken", "salsa", "rice"],
level=1,
constraints=[],
)
result = engine.suggest(req)
assembly_ids = [s.id for s in result.suggestions if s.id < 0]
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}"
# ── _within_time tests (kiwi#52) ──────────────────────────────────────────────
def test_within_time_no_directions_passes():
"""Empty directions -> True (don't hide recipes with no data)."""
from app.services.recipe.recipe_engine import _within_time
assert _within_time([], max_total_min=10) is True
def test_within_time_no_time_signals_passes():
"""Directions with no time signals -> total_min == 0 -> True."""
from app.services.recipe.recipe_engine import _within_time
steps = ["mix together", "pour over ice", "serve immediately"]
assert _within_time(steps, max_total_min=5) is True
def test_within_time_under_limit_passes():
"""Recipe with 10 min total and limit of 15 -> passes."""
from app.services.recipe.recipe_engine import _within_time
steps = ["cook for 10 minutes", "serve"]
assert _within_time(steps, max_total_min=15) is True
def test_within_time_at_limit_passes():
"""Recipe exactly at limit -> passes (inclusive boundary)."""
from app.services.recipe.recipe_engine import _within_time
steps = ["simmer for 10 minutes"]
assert _within_time(steps, max_total_min=10) is True
def test_within_time_over_limit_fails():
"""Recipe with 45 min total and limit of 30 -> fails."""
from app.services.recipe.recipe_engine import _within_time
steps = ["brown onions for 15 minutes", "simmer for 30 minutes"]
assert _within_time(steps, max_total_min=30) is False