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
This commit is contained in:
parent
70205ebb25
commit
b1e187c779
8 changed files with 1050 additions and 12 deletions
|
|
@ -37,6 +37,7 @@ from app.services.recipe.browser_domains import (
|
|||
get_subcategory_names,
|
||||
)
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
from app.services.heimdall_orch import check_orch_budget
|
||||
from app.tiers import can_use
|
||||
|
||||
|
|
@ -293,6 +294,25 @@ async def browse_recipes(
|
|||
sort=sort,
|
||||
)
|
||||
|
||||
# ── Attach time/effort signals to each browse result ────────────────
|
||||
import json as _json
|
||||
for recipe_row in result.get("recipes", []):
|
||||
directions_raw = recipe_row.get("directions") or []
|
||||
if isinstance(directions_raw, str):
|
||||
try:
|
||||
directions_raw = _json.loads(directions_raw)
|
||||
except Exception:
|
||||
directions_raw = []
|
||||
if directions_raw:
|
||||
_profile = parse_time_effort(directions_raw)
|
||||
recipe_row["active_min"] = _profile.active_min
|
||||
recipe_row["passive_min"] = _profile.passive_min
|
||||
else:
|
||||
recipe_row["active_min"] = None
|
||||
recipe_row["passive_min"] = None
|
||||
# Remove directions from browse payload — not needed by the card UI
|
||||
recipe_row.pop("directions", None)
|
||||
|
||||
# Community tag fallback: if FTS returned nothing for a subcategory,
|
||||
# check whether accepted community tags exist for this location and
|
||||
# fetch those corpus recipes directly by ID.
|
||||
|
|
@ -310,6 +330,22 @@ async def browse_recipes(
|
|||
offset = (page - 1) * page_size
|
||||
paged_ids = community_ids[offset: offset + page_size]
|
||||
recipes = store.fetch_recipes_by_ids(paged_ids, pantry_list)
|
||||
import json as _json_c
|
||||
for recipe_row in recipes:
|
||||
directions_raw = recipe_row.get("directions") or []
|
||||
if isinstance(directions_raw, str):
|
||||
try:
|
||||
directions_raw = _json_c.loads(directions_raw)
|
||||
except Exception:
|
||||
directions_raw = []
|
||||
if directions_raw:
|
||||
_profile = parse_time_effort(directions_raw)
|
||||
recipe_row["active_min"] = _profile.active_min
|
||||
recipe_row["passive_min"] = _profile.passive_min
|
||||
else:
|
||||
recipe_row["active_min"] = None
|
||||
recipe_row["passive_min"] = None
|
||||
recipe_row.pop("directions", None)
|
||||
result = {
|
||||
"recipes": recipes,
|
||||
"total": len(community_ids),
|
||||
|
|
@ -434,4 +470,57 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session))
|
|||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||
return recipe
|
||||
|
||||
# Normalize corpus record into RecipeSuggestion shape so RecipeDetailPanel
|
||||
# can render it without knowing it came from a direct DB lookup.
|
||||
ingredient_names = recipe.get("ingredient_names") or []
|
||||
if isinstance(ingredient_names, str):
|
||||
import json as _json
|
||||
try:
|
||||
ingredient_names = _json.loads(ingredient_names)
|
||||
except Exception:
|
||||
ingredient_names = []
|
||||
|
||||
_directions_for_te = recipe.get("directions") or []
|
||||
if isinstance(_directions_for_te, str):
|
||||
import json as _json2
|
||||
try:
|
||||
_directions_for_te = _json2.loads(_directions_for_te)
|
||||
except Exception:
|
||||
_directions_for_te = []
|
||||
|
||||
if _directions_for_te:
|
||||
_te = parse_time_effort(_directions_for_te)
|
||||
_time_effort_out: dict | None = {
|
||||
"active_min": _te.active_min,
|
||||
"passive_min": _te.passive_min,
|
||||
"total_min": _te.total_min,
|
||||
"effort_label": _te.effort_label,
|
||||
"equipment": _te.equipment,
|
||||
"step_analyses": [
|
||||
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
|
||||
for sa in _te.step_analyses
|
||||
],
|
||||
}
|
||||
else:
|
||||
_time_effort_out = None
|
||||
|
||||
return {
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title", ""),
|
||||
"match_count": 0,
|
||||
"matched_ingredients": ingredient_names,
|
||||
"missing_ingredients": [],
|
||||
"directions": recipe.get("directions") or [],
|
||||
"prep_notes": [],
|
||||
"swap_candidates": [],
|
||||
"element_coverage": {},
|
||||
"notes": recipe.get("notes") or "",
|
||||
"level": 1,
|
||||
"is_wildcard": False,
|
||||
"nutrition": None,
|
||||
"source_url": recipe.get("source_url") or None,
|
||||
"complexity": None,
|
||||
"estimated_time_min": None,
|
||||
"time_effort": _time_effort_out,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1190,7 +1190,7 @@ class Store:
|
|||
|
||||
cols = (
|
||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
||||
f" calories, fat_g, protein_g, sodium_mg, directions FROM {c}recipes"
|
||||
)
|
||||
|
||||
if keywords is None:
|
||||
|
|
@ -1268,7 +1268,7 @@ class Store:
|
|||
ph = ",".join("?" * len(recipe_ids))
|
||||
rows = self._fetch_all(
|
||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||
f" calories, fat_g, protein_g, sodium_mg"
|
||||
f" calories, fat_g, protein_g, sodium_mg, directions"
|
||||
f" FROM {c}recipes WHERE id IN ({ph}) ORDER BY id ASC",
|
||||
tuple(recipe_ids),
|
||||
)
|
||||
|
|
@ -1280,6 +1280,7 @@ class Store:
|
|||
"category": r["category"],
|
||||
"match_pct": None,
|
||||
}
|
||||
entry["directions"] = r.get("directions")
|
||||
if pantry_set:
|
||||
names = r.get("ingredient_names") or []
|
||||
if names:
|
||||
|
|
@ -1318,7 +1319,7 @@ class Store:
|
|||
|
||||
# ── Fetch candidate pool from FTS ────────────────────────────────────
|
||||
base_cols = (
|
||||
f"SELECT r.id, r.title, r.category, r.ingredient_names"
|
||||
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions"
|
||||
f" FROM {c}recipes r"
|
||||
)
|
||||
|
||||
|
|
@ -1385,6 +1386,7 @@ class Store:
|
|||
"title": row["title"],
|
||||
"category": row["category"],
|
||||
"match_pct": match_pct,
|
||||
"directions": row.get("directions"),
|
||||
})
|
||||
|
||||
scored.sort(key=lambda r: (-(r["match_pct"] or 0), r["id"]))
|
||||
|
|
|
|||
197
app/services/recipe/time_effort.py
Normal file
197
app/services/recipe/time_effort.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
Runtime parser for active/passive time split and equipment detection.
|
||||
|
||||
Operates over a list of direction strings. No I/O — pure Python functions.
|
||||
Sub-millisecond for up to 20 recipes (20 × ~10 steps each = 200 regex calls).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
# ── Passive step keywords (whole-word, case-insensitive) ──────────────────
|
||||
|
||||
_PASSIVE_PATTERNS: Final[list[str]] = [
|
||||
"simmer", "bake", "roast", "broil", "refrigerate", "marinate",
|
||||
"chill", "cool", "freeze", "rest", "stand", "set", "soak",
|
||||
"steep", "proof", "rise", "let", "wait", "overnight", "braise",
|
||||
r"slow\s+cook", r"pressure\s+cook",
|
||||
]
|
||||
|
||||
# Pre-compiled as a single alternation — avoids re-compiling on every call.
|
||||
_PASSIVE_RE: re.Pattern[str] = re.compile(
|
||||
r"\b(?:" + "|".join(_PASSIVE_PATTERNS) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ── Time extraction regex ─────────────────────────────────────────────────
|
||||
|
||||
# Two-branch pattern:
|
||||
# Branch A (groups 1-3): range "15-20 minutes", "15–20 min"
|
||||
# Branch B (groups 4-5): single "10 minutes", "2 hours", "30 sec"
|
||||
#
|
||||
# Separator characters: plain hyphen (-), en-dash (–), or literal "-to-"
|
||||
_TIME_RE: re.Pattern[str] = re.compile(
|
||||
r"(\d+)\s*(?:[-\u2013]|-to-)\s*(\d+)\s*(hour|hr|minute|min|second|sec)s?"
|
||||
r"|"
|
||||
r"(\d+)\s*(hour|hr|minute|min|second|sec)s?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_MAX_MINUTES_PER_STEP: Final[int] = 480 # 8 hours sanity cap
|
||||
|
||||
# ── Equipment detection (keyword → label, in detection priority order) ────
|
||||
|
||||
_EQUIPMENT_RULES: Final[list[tuple[re.Pattern[str], str]]] = [
|
||||
(re.compile(r"\b(?:chop|dice|mince|slice|julienne)\b", re.IGNORECASE), "Knife"),
|
||||
(re.compile(r"\b(?:skillet|sauté|saute|fry|sear|pan-fry|pan fry)\b", re.IGNORECASE), "Skillet"),
|
||||
(re.compile(r"\b(?:wooden spoon|spatula|stir|fold)\b", re.IGNORECASE), "Spoon"),
|
||||
(re.compile(r"\b(?:pot|boil|simmer|blanch|stock)\b", re.IGNORECASE), "Pot"),
|
||||
(re.compile(r"\b(?:oven|bake|roast|preheat|broil)\b", re.IGNORECASE), "Oven"),
|
||||
(re.compile(r"\b(?:blender|blend|purée|puree|food processor)\b", re.IGNORECASE), "Blender"),
|
||||
(re.compile(r"\b(?:stand mixer|hand mixer|whip|beat)\b", re.IGNORECASE), "Mixer"),
|
||||
(re.compile(r"\b(?:grill|barbecue|char|griddle)\b", re.IGNORECASE), "Grill"),
|
||||
(re.compile(r"\b(?:slow cooker|crockpot|low and slow)\b", re.IGNORECASE), "Slow cooker"),
|
||||
(re.compile(r"\b(?:pressure cooker|instant pot)\b", re.IGNORECASE), "Pressure cooker"),
|
||||
(re.compile(r"\b(?:drain|strain|colander|rinse pasta)\b", re.IGNORECASE), "Colander"),
|
||||
]
|
||||
|
||||
# ── Dataclasses ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAnalysis:
|
||||
"""Analysis result for a single direction step."""
|
||||
is_passive: bool
|
||||
detected_minutes: int | None # None when no time mention found in text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimeEffortProfile:
|
||||
"""Aggregated time and effort profile for a full recipe."""
|
||||
active_min: int # total minutes requiring active attention
|
||||
passive_min: int # total minutes the cook can step away
|
||||
total_min: int # active_min + passive_min
|
||||
step_analyses: list[StepAnalysis] # one entry per direction step
|
||||
equipment: list[str] # ordered, deduplicated equipment labels
|
||||
effort_label: str # "quick" | "moderate" | "involved"
|
||||
|
||||
|
||||
# ── Core parsing logic ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_minutes(text: str) -> int | None:
|
||||
"""Return the number of minutes mentioned in text, or None.
|
||||
|
||||
Range values (e.g. "15-20 minutes") return the integer midpoint.
|
||||
Hours are converted to minutes. Seconds are rounded up to 1 minute minimum.
|
||||
Result is capped at _MAX_MINUTES_PER_STEP.
|
||||
"""
|
||||
m = _TIME_RE.search(text)
|
||||
if m is None:
|
||||
return None
|
||||
|
||||
if m.group(1) is not None:
|
||||
# Branch A: range match (e.g. "15-20 minutes")
|
||||
low = int(m.group(1))
|
||||
high = int(m.group(2))
|
||||
unit = m.group(3).lower()
|
||||
raw_value: float = (low + high) / 2
|
||||
else:
|
||||
# Branch B: single value match (e.g. "10 minutes")
|
||||
low = int(m.group(4))
|
||||
unit = m.group(5).lower()
|
||||
raw_value = float(low)
|
||||
|
||||
if unit in ("hour", "hr"):
|
||||
minutes: float = raw_value * 60
|
||||
elif unit in ("second", "sec"):
|
||||
minutes = max(1.0, math.ceil(raw_value / 60))
|
||||
else:
|
||||
minutes = raw_value
|
||||
|
||||
return min(int(minutes), _MAX_MINUTES_PER_STEP)
|
||||
|
||||
|
||||
def _classify_passive(text: str) -> bool:
|
||||
"""Return True if the step text matches any passive keyword (whole-word)."""
|
||||
return _PASSIVE_RE.search(text) is not None
|
||||
|
||||
|
||||
def _detect_equipment(all_text: str, has_passive: bool) -> list[str]:
|
||||
"""Return ordered, deduplicated list of equipment labels detected in text.
|
||||
|
||||
all_text should be all direction steps joined with spaces.
|
||||
has_passive controls whether 'Timer' is appended at the end.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for pattern, label in _EQUIPMENT_RULES:
|
||||
if label not in seen and pattern.search(all_text):
|
||||
seen.add(label)
|
||||
result.append(label)
|
||||
if has_passive and "Timer" not in seen:
|
||||
result.append("Timer")
|
||||
return result
|
||||
|
||||
|
||||
def _effort_label(step_count: int) -> str:
|
||||
"""Derive effort label from step count."""
|
||||
if step_count <= 3:
|
||||
return "quick"
|
||||
if step_count <= 7:
|
||||
return "moderate"
|
||||
return "involved"
|
||||
|
||||
|
||||
def parse_time_effort(directions: list[str]) -> TimeEffortProfile:
|
||||
"""Parse a list of direction strings into a TimeEffortProfile.
|
||||
|
||||
Returns a zero-value profile with empty lists when directions is empty.
|
||||
Never raises — all failures silently produce sensible defaults.
|
||||
"""
|
||||
if not directions:
|
||||
return TimeEffortProfile(
|
||||
active_min=0,
|
||||
passive_min=0,
|
||||
total_min=0,
|
||||
step_analyses=[],
|
||||
equipment=[],
|
||||
effort_label="quick",
|
||||
)
|
||||
|
||||
step_analyses: list[StepAnalysis] = []
|
||||
active_min = 0
|
||||
passive_min = 0
|
||||
has_any_passive = False
|
||||
|
||||
for step in directions:
|
||||
is_passive = _classify_passive(step)
|
||||
detected = _extract_minutes(step)
|
||||
|
||||
if is_passive:
|
||||
has_any_passive = True
|
||||
if detected is not None:
|
||||
passive_min += detected
|
||||
else:
|
||||
if detected is not None:
|
||||
active_min += detected
|
||||
|
||||
step_analyses.append(StepAnalysis(
|
||||
is_passive=is_passive,
|
||||
detected_minutes=detected,
|
||||
))
|
||||
|
||||
combined_text = " ".join(directions)
|
||||
equipment = _detect_equipment(combined_text, has_any_passive)
|
||||
|
||||
return TimeEffortProfile(
|
||||
active_min=active_min,
|
||||
passive_min=passive_min,
|
||||
total_min=active_min + passive_min,
|
||||
step_analyses=step_analyses,
|
||||
equipment=equipment,
|
||||
effort_label=_effort_label(len(directions)),
|
||||
)
|
||||
|
|
@ -163,6 +163,19 @@
|
|||
{{ Math.round(recipe.match_pct * 100) }}%
|
||||
</span>
|
||||
|
||||
<!-- Time & effort split pill -->
|
||||
<span
|
||||
v-if="recipe.active_min !== null"
|
||||
class="time-split-pill"
|
||||
:title="`~${formatMin(recipe.active_min)} active · ~${formatMin(recipe.passive_min ?? 0)} passive`"
|
||||
>
|
||||
<span class="pill-active">🧑🍳 ~{{ formatMin(recipe.active_min) }}</span>
|
||||
<span
|
||||
v-if="recipe.passive_min !== null && recipe.passive_min > 0"
|
||||
class="pill-passive"
|
||||
>💤 ~{{ formatMin(recipe.passive_min) }}</span>
|
||||
</span>
|
||||
|
||||
<!-- Save toggle -->
|
||||
<button
|
||||
class="btn btn-secondary btn-xs"
|
||||
|
|
@ -340,6 +353,18 @@ function matchBadgeClass(pct: number): string {
|
|||
return 'status-secondary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format minutes as a compact display string.
|
||||
* < 60 → "15m"
|
||||
* >= 60 → "1h 30m" (omits minutes when zero: "2h")
|
||||
*/
|
||||
function formatMin(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadingDomains.value = true
|
||||
try {
|
||||
|
|
@ -700,6 +725,37 @@ async function submitTag() {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Time & effort split pill ──────────────────────────────────────────── */
|
||||
.time-split-pill {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-xs, 0.72rem);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pill-active {
|
||||
padding: 2px 6px;
|
||||
background: rgba(232, 168, 32, 0.18);
|
||||
color: #f0bc48;
|
||||
border-radius: var(--radius-pill, 999px) 0 0 var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
/* When there is no passive segment, active gets full pill rounding */
|
||||
.time-split-pill:not(:has(.pill-passive)) .pill-active {
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.pill-passive {
|
||||
padding: 2px 6px;
|
||||
background: rgba(41, 128, 185, 0.15);
|
||||
color: #5dade2;
|
||||
border-radius: 0 var(--radius-pill, 999px) var(--radius-pill, 999px) 0;
|
||||
}
|
||||
|
||||
/* ── Community tag CTA ──────────────────────────────────────────────────── */
|
||||
.tag-cta {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,15 @@
|
|||
</div>
|
||||
|
||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||
<div class="ingredients-grid">
|
||||
<details open class="ingredients-collapsible">
|
||||
<summary class="ingredients-collapsible-summary">
|
||||
Ingredients
|
||||
<span class="ingr-summary-counts">
|
||||
<span v-if="recipe.matched_ingredients?.length" class="ingr-count ingr-count-have">{{ recipe.matched_ingredients.length }} ✓</span>
|
||||
<span v-if="recipe.missing_ingredients?.length" class="ingr-count ingr-count-need">{{ recipe.missing_ingredients.length }} needed</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ingredients-grid">
|
||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||
<h3 class="col-label col-label-have">From your pantry</h3>
|
||||
<ul class="ingredient-list">
|
||||
|
|
@ -97,6 +105,35 @@
|
|||
@click="toggleSelectAll"
|
||||
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Time & effort summary cards -->
|
||||
<div v-if="recipe.time_effort" class="effort-summary">
|
||||
<div class="effort-card effort-card-active">
|
||||
<span class="effort-label">Active</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.active_min) }}</span>
|
||||
</div>
|
||||
<div v-if="recipe.time_effort.passive_min > 0" class="effort-card effort-card-passive">
|
||||
<span class="effort-label">Hands-off</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.passive_min) }}</span>
|
||||
</div>
|
||||
<div class="effort-card effort-card-total">
|
||||
<span class="effort-label">Total</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.total_min) }}</span>
|
||||
</div>
|
||||
<div class="effort-level-badge" :class="'effort-' + recipe.time_effort.effort_label">
|
||||
{{ recipe.time_effort.effort_label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment chips -->
|
||||
<div v-if="recipe.time_effort?.equipment?.length" class="equipment-chips">
|
||||
<span
|
||||
v-for="eq in recipe.time_effort.equipment"
|
||||
:key="eq"
|
||||
class="equipment-chip"
|
||||
>{{ EQUIPMENT_ICONS[eq] ?? '🍴' }} {{ eq }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Swap candidates -->
|
||||
|
|
@ -145,13 +182,27 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Directions -->
|
||||
<div v-if="recipe.directions.length > 0" class="detail-section">
|
||||
<h3 class="section-label">Steps</h3>
|
||||
<ol class="directions-list">
|
||||
<li v-for="(step, i) in recipe.directions" :key="i" class="text-sm direction-step">{{ step }}</li>
|
||||
<!-- Directions (annotated) -->
|
||||
<details open v-if="recipe.directions.length > 0" class="steps-collapsible">
|
||||
<summary class="steps-collapsible-summary">
|
||||
Steps <span class="steps-count">({{ recipe.directions.length }})</span>
|
||||
</summary>
|
||||
<ol class="directions-list directions-list-annotated">
|
||||
<li
|
||||
v-for="(step, i) in recipe.directions"
|
||||
:key="i"
|
||||
class="text-sm direction-step direction-step-annotated"
|
||||
:class="{ 'step-passive': stepAnalysis(i)?.is_passive }"
|
||||
>
|
||||
<div class="step-badge-row">
|
||||
<span v-if="stepAnalysis(i)?.is_passive" class="step-type-badge step-type-wait">Wait</span>
|
||||
<span v-else-if="stepAnalysis(i)" class="step-type-badge step-type-active">Active</span>
|
||||
</div>
|
||||
<p class="step-text">{{ step }}</p>
|
||||
<p v-if="passiveHint(stepAnalysis(i))" class="step-passive-hint">{{ passiveHint(stepAnalysis(i)) }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
|
||||
<div style="height: var(--spacing-xl)" />
|
||||
|
|
@ -217,7 +268,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { inventoryAPI } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink, StepAnalysis, TimeEffortProfile } from '../services/api'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
|
|
@ -325,6 +376,39 @@ function scaleIngredient(ing: string, scale: number): string {
|
|||
return scaled + ing.slice(m[0].length)
|
||||
}
|
||||
|
||||
// Time & effort helpers
|
||||
function formatDetailMin(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h} hr` : `${h} hr ${m} min`
|
||||
}
|
||||
|
||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
||||
oven: '♨',
|
||||
stovetop: '🔥',
|
||||
blender: '⚡',
|
||||
'food processor': '⚡',
|
||||
microwave: '📡',
|
||||
grill: '🔥',
|
||||
'slow cooker': '⏲',
|
||||
'instant pot': '⏲',
|
||||
mixer: '🌀',
|
||||
skillet: '🍳',
|
||||
'cast iron': '🍳',
|
||||
wok: '🍳',
|
||||
}
|
||||
|
||||
function stepAnalysis(i: number): StepAnalysis | null {
|
||||
return props.recipe.time_effort?.step_analyses?.[i] ?? null
|
||||
}
|
||||
|
||||
function passiveHint(analysis: StepAnalysis | null): string {
|
||||
if (!analysis?.is_passive) return ''
|
||||
if (analysis.detected_minutes) return `~${analysis.detected_minutes} min hands-off`
|
||||
return 'Hands-off time'
|
||||
}
|
||||
|
||||
// Shopping: add purchased ingredients to pantry
|
||||
const checkedIngredients = ref<Set<string>>(new Set())
|
||||
const addingToPantry = ref(false)
|
||||
|
|
@ -871,6 +955,234 @@ function handleCook() {
|
|||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Ingredients collapsible ────────────────────────────── */
|
||||
.ingredients-collapsible {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open].ingredients-collapsible .ingredients-collapsible-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ingr-summary-counts {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ingr-count {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.ingr-count-have {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.ingr-count-need {
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
color: var(--color-warning, #ca8a04);
|
||||
}
|
||||
|
||||
/* ── Effort summary cards ───────────────────────────────── */
|
||||
.effort-summary {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.effort-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.effort-card-active {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
}
|
||||
|
||||
.effort-card-passive {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
}
|
||||
|
||||
.effort-card-total {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
.effort-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.effort-value {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.effort-level-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.effort-quick {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.effort-moderate {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
}
|
||||
|
||||
.effort-involved {
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
color: var(--color-warning, #ca8a04);
|
||||
}
|
||||
|
||||
/* ── Equipment chips ────────────────────────────────────── */
|
||||
.equipment-chips {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.equipment-chip {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Steps collapsible ──────────────────────────────────── */
|
||||
.steps-collapsible {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.steps-collapsible-summary {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: var(--spacing-xs) 0;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.steps-collapsible-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.steps-collapsible-summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open].steps-collapsible .steps-collapsible-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.steps-count {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.directions-list-annotated {
|
||||
padding-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.direction-step-annotated {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-left: 3px solid var(--color-border);
|
||||
}
|
||||
|
||||
.step-passive {
|
||||
border-left-color: var(--color-info-light, #60a5fa);
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
}
|
||||
|
||||
.step-badge-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.step-type-active {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.step-type-wait {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.step-passive-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Sticky footer ──────────────────────────────────────── */
|
||||
.detail-footer {
|
||||
padding: var(--spacing-md);
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ export interface RecipeSuggestion {
|
|||
source_url: string | null
|
||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||
estimated_time_min: number | null
|
||||
time_effort: TimeEffortProfile | null
|
||||
}
|
||||
|
||||
export interface NutritionFilters {
|
||||
|
|
@ -911,11 +912,29 @@ export interface BrowserSubcategory {
|
|||
recipe_count: number
|
||||
}
|
||||
|
||||
// ── Time & Effort types ───────────────────────────────────────────────────
|
||||
|
||||
export interface StepAnalysis {
|
||||
is_passive: boolean
|
||||
detected_minutes: number | null
|
||||
}
|
||||
|
||||
export interface TimeEffortProfile {
|
||||
active_min: number
|
||||
passive_min: number
|
||||
total_min: number
|
||||
effort_label: 'quick' | 'moderate' | 'involved'
|
||||
equipment: string[]
|
||||
step_analyses: StepAnalysis[]
|
||||
}
|
||||
|
||||
export interface BrowserRecipe {
|
||||
id: number
|
||||
title: string
|
||||
category: string | null
|
||||
match_pct: number | null
|
||||
active_min: number | null
|
||||
passive_min: number | null
|
||||
}
|
||||
|
||||
export interface BrowserResult {
|
||||
|
|
|
|||
153
tests/api/test_browse_time_effort.py
Normal file
153
tests/api/test_browse_time_effort.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""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
|
||||
210
tests/test_services/test_time_effort.py
Normal file
210
tests/test_services/test_time_effort.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""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 15–20 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]
|
||||
Loading…
Reference in a new issue