kiwi/tests/api/test_browse_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

153 lines
5.6 KiB
Python

"""Tests for active_min/passive_min fields on browse endpoint responses."""
import pytest
from unittest.mock import MagicMock, patch
from app.services.recipe.time_effort import parse_time_effort
class TestBrowseTimeEffortFields:
"""Unit-level: verify that browse result dicts gain active_min/passive_min."""
def _make_recipe_row(self, recipe_id: int, directions: list[str]) -> dict:
"""Build a minimal recipe row as browse_recipes would return."""
import json
return {
"id": recipe_id,
"title": f"Recipe {recipe_id}",
"category": "Italian",
"match_pct": None,
"directions": json.dumps(directions), # stored as JSON string
}
def test_active_passive_attached_when_directions_present(self):
"""Simulate the enrichment logic that the endpoint applies."""
import json
row = self._make_recipe_row(1, ["Chop onion.", "Simmer for 20 minutes."])
# Reproduce the enrichment logic from the endpoint:
directions = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = []
if directions:
profile = parse_time_effort(directions)
row["active_min"] = profile.active_min
row["passive_min"] = profile.passive_min
else:
row["active_min"] = None
row["passive_min"] = None
assert row["active_min"] == 0 # no active time found
assert row["passive_min"] == 20
def test_null_when_directions_empty(self):
"""active_min and passive_min are None when directions list is empty."""
import json
row = self._make_recipe_row(2, [])
directions = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = []
if directions:
profile = parse_time_effort(directions)
row["active_min"] = profile.active_min
row["passive_min"] = profile.passive_min
else:
row["active_min"] = None
row["passive_min"] = None
assert row["active_min"] is None
assert row["passive_min"] is None
def test_null_when_directions_missing_key(self):
"""active_min and passive_min are None when key is absent."""
row = {"id": 3, "title": "Test", "category": "X", "match_pct": None}
directions = row.get("directions") or []
if isinstance(directions, str):
try:
import json
directions = json.loads(directions)
except Exception:
directions = []
if directions:
profile = parse_time_effort(directions)
row["active_min"] = profile.active_min
row["passive_min"] = profile.passive_min
else:
row["active_min"] = None
row["passive_min"] = None
assert row["active_min"] is None
assert row["passive_min"] is None
class TestDetailTimeEffortField:
"""Verify that the detail endpoint response gains a time_effort key."""
def test_time_effort_field_structure(self):
"""Detail endpoint must return the full TimeEffortProfile shape."""
import json
from app.services.recipe.time_effort import parse_time_effort
directions = [
"Dice the onion.",
"Sear chicken for 5 minutes.",
"Simmer sauce for 20 minutes.",
]
profile = parse_time_effort(directions)
# Simulate what the endpoint serialises
time_effort_dict = {
"active_min": profile.active_min,
"passive_min": profile.passive_min,
"total_min": profile.total_min,
"effort_label": profile.effort_label,
"equipment": profile.equipment,
"step_analyses": [
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
for sa in profile.step_analyses
],
}
assert time_effort_dict["active_min"] == 5
assert time_effort_dict["passive_min"] == 20
assert time_effort_dict["total_min"] == 25
assert time_effort_dict["effort_label"] == "quick" # 3 steps
assert isinstance(time_effort_dict["equipment"], list)
assert len(time_effort_dict["step_analyses"]) == 3
assert time_effort_dict["step_analyses"][2]["is_passive"] is True
def test_time_effort_none_when_no_directions(self):
"""time_effort should be None when recipe has empty directions."""
from app.services.recipe.time_effort import parse_time_effort
recipe_dict = {
"id": 99,
"title": "Empty",
"directions": [],
}
directions = recipe_dict.get("directions") or []
if directions:
profile = parse_time_effort(directions)
recipe_dict["time_effort"] = {
"active_min": profile.active_min,
"passive_min": profile.passive_min,
"total_min": profile.total_min,
"effort_label": profile.effort_label,
"equipment": profile.equipment,
"step_analyses": [
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
for sa in profile.step_analyses
],
}
else:
recipe_dict["time_effort"] = None
assert recipe_dict["time_effort"] is None