kiwi/tests/test_services/test_time_effort.py
pyr0ball b1e187c779 feat: time & effort signals — active/passive split, effort cards, annotated steps (kiwi#50)
- Add app/services/recipe/time_effort.py: parse_time_effort(), TimeEffortProfile,
  StepAnalysis dataclasses; two-branch regex for time ranges and single values;
  whole-word passive keyword detection; 480 min/step cap; 1825 day global cap
- Add directions to browse_recipes and _browse_by_match SELECT queries in store.py
- Enrich browse and detail endpoints with active_min/passive_min/time_effort fields
- Add StepAnalysis, TimeEffortProfile TS interfaces to api.ts
- RecipeBrowserPanel: split pill badge showing active/passive time
- RecipeDetailPanel: collapsible ingredients summary, effort cards (Active/Hands-off/Total),
  equipment chips, annotated step list with Active/Wait badges and passive hints
- 45 new tests (40 unit + 5 API); 215 total passing
2026-04-24 09:29:54 -07:00

210 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for app.services.recipe.time_effort — run RED before implementing."""
import pytest
from app.services.recipe.time_effort import (
TimeEffortProfile,
StepAnalysis,
parse_time_effort,
)
# ── Step classification ────────────────────────────────────────────────────
class TestPassiveClassification:
def test_simmer_is_passive(self):
result = parse_time_effort(["Simmer for 10 minutes."])
assert result.step_analyses[0].is_passive is True
def test_bake_is_passive(self):
result = parse_time_effort(["Bake at 375°F for 30 minutes."])
assert result.step_analyses[0].is_passive is True
def test_chop_is_active(self):
result = parse_time_effort(["Chop the onion finely."])
assert result.step_analyses[0].is_passive is False
def test_sear_is_active(self):
result = parse_time_effort(["Sear chicken over high heat."])
assert result.step_analyses[0].is_passive is False
def test_let_rest_is_passive(self):
result = parse_time_effort(["Let the dough rest for 20 minutes."])
assert result.step_analyses[0].is_passive is True
def test_passive_keywords_matched_as_whole_words(self):
# "settle" contains "set" — must NOT match as passive
result = parse_time_effort(["Settle the dish on the table."])
assert result.step_analyses[0].is_passive is False
def test_overnight_is_passive(self):
result = parse_time_effort(["Marinate overnight in the fridge."])
assert result.step_analyses[0].is_passive is True
def test_slow_cook_multiword_is_passive(self):
result = parse_time_effort(["Slow cook on low for 6 hours."])
assert result.step_analyses[0].is_passive is True
def test_pressure_cook_multiword_is_passive(self):
result = parse_time_effort(["Pressure cook on high for 15 minutes."])
assert result.step_analyses[0].is_passive is True
# ── Time extraction ────────────────────────────────────────────────────────
class TestTimeExtraction:
def test_simple_minutes(self):
result = parse_time_effort(["Cook for 10 minutes."])
assert result.step_analyses[0].detected_minutes == 10
def test_simple_hours_converted(self):
result = parse_time_effort(["Braise for 2 hours."])
assert result.step_analyses[0].detected_minutes == 120
def test_range_takes_midpoint(self):
# "15-20 minutes" → midpoint = 17 (int division: (15+20)//2 = 17)
result = parse_time_effort(["Cook for 15-20 minutes."])
assert result.step_analyses[0].detected_minutes == 17
def test_range_with_endash(self):
result = parse_time_effort(["Simmer for 1520 minutes."])
assert result.step_analyses[0].detected_minutes == 17
def test_abbreviated_min(self):
result = parse_time_effort(["Heat oil for 5 min."])
assert result.step_analyses[0].detected_minutes == 5
def test_abbreviated_hr(self):
result = parse_time_effort(["Rest for 1 hr."])
assert result.step_analyses[0].detected_minutes == 60
def test_no_time_returns_none(self):
result = parse_time_effort(["Add salt to taste."])
assert result.step_analyses[0].detected_minutes is None
def test_cap_at_480_minutes(self):
# 10 hours would be 600 min — capped at 480
result = parse_time_effort(["Ferment for 10 hours."])
assert result.step_analyses[0].detected_minutes == 480
def test_seconds_converted(self):
result = parse_time_effort(["Blend for 30 seconds."])
assert result.step_analyses[0].detected_minutes == 1 # rounds up: ceil(30/60) or 1 as min floor
# ── Time totals ────────────────────────────────────────────────────────────
class TestTimeTotals:
def test_active_passive_split(self):
steps = [
"Chop onions finely.", # active, no time
"Sear chicken for 5 minutes per side.", # active, 5 min
"Simmer for 20 minutes.", # passive, 20 min
]
result = parse_time_effort(steps)
assert result.active_min == 5
assert result.passive_min == 20
assert result.total_min == 25
def test_all_active_passive_zero(self):
steps = ["Dice vegetables.", "Season with salt.", "Plate and serve."]
result = parse_time_effort(steps)
assert result.passive_min == 0
def test_zero_directions_returns_zero_profile(self):
result = parse_time_effort([])
assert result.active_min == 0
assert result.passive_min == 0
assert result.total_min == 0
assert result.step_analyses == []
assert result.equipment == []
assert result.effort_label == "quick"
# ── Effort label ───────────────────────────────────────────────────────────
class TestEffortLabel:
def test_one_step_is_quick(self):
result = parse_time_effort(["Serve cold."])
assert result.effort_label == "quick"
def test_three_steps_is_quick(self):
result = parse_time_effort(["a", "b", "c"])
assert result.effort_label == "quick"
def test_four_steps_is_moderate(self):
result = parse_time_effort(["a", "b", "c", "d"])
assert result.effort_label == "moderate"
def test_seven_steps_is_moderate(self):
result = parse_time_effort(["a"] * 7)
assert result.effort_label == "moderate"
def test_eight_steps_is_involved(self):
result = parse_time_effort(["a"] * 8)
assert result.effort_label == "involved"
# ── Equipment detection ────────────────────────────────────────────────────
class TestEquipmentDetection:
def test_knife_detected(self):
result = parse_time_effort(["Dice the onion.", "Mince the garlic."])
assert "Knife" in result.equipment
def test_skillet_keyword_fry(self):
result = parse_time_effort(["Pan-fry the chicken over medium heat."])
assert "Skillet" in result.equipment
def test_oven_detected(self):
result = parse_time_effort(["Preheat oven to 400°F.", "Bake for 25 minutes."])
assert "Oven" in result.equipment
def test_pot_detected(self):
result = parse_time_effort(["Bring a large pot of water to boil."])
assert "Pot" in result.equipment
def test_timer_added_when_any_passive_step(self):
result = parse_time_effort(["Chop onion.", "Simmer for 10 minutes."])
assert "Timer" in result.equipment
def test_no_timer_when_all_active(self):
result = parse_time_effort(["Chop vegetables.", "Toss with dressing."])
assert "Timer" not in result.equipment
def test_equipment_deduplicated(self):
# Multiple steps with 'dice' should still yield only one Knife
result = parse_time_effort(["Dice onion.", "Dice carrot.", "Dice celery."])
assert result.equipment.count("Knife") == 1
def test_no_equipment_when_empty(self):
result = parse_time_effort([])
assert result.equipment == []
def test_slow_cooker_detected(self):
result = parse_time_effort(["Place everything in the slow cooker."])
assert "Slow cooker" in result.equipment
def test_pressure_cooker_detected(self):
result = parse_time_effort(["Set instant pot to high pressure."])
assert "Pressure cooker" in result.equipment
def test_colander_detected(self):
result = parse_time_effort(["Drain the pasta through a colander."])
assert "Colander" in result.equipment
def test_blender_detected(self):
result = parse_time_effort(["Blend until smooth."])
assert "Blender" in result.equipment
# ── Dataclass immutability ────────────────────────────────────────────────
class TestImmutability:
def test_time_effort_profile_is_frozen(self):
result = parse_time_effort(["Chop onion."])
with pytest.raises((AttributeError, TypeError)):
result.active_min = 99 # type: ignore[misc]
def test_step_analysis_is_frozen(self):
result = parse_time_effort(["Simmer for 10 min."])
with pytest.raises((AttributeError, TypeError)):
result.step_analyses[0].is_passive = False # type: ignore[misc]