Public Access
feat(backend): implement unit conversion for cost calculation
- Add UnitConverter (normalization, within-family, density tables) - Update cost.py to convert recipe qty to grocery price unit - Update generate.py _load_match_index to fetch ingredient name + unit - Fix orchestrator email/shopping-list cost loops to use conversion - Fix missing Ingredient import in generate.py - Add 19 unit tests
This commit is contained in:
@@ -23,6 +23,7 @@ from app.services.email import get_email_backend
|
|||||||
from app.services.orchestrator.alerts import send_admin_alert
|
from app.services.orchestrator.alerts import send_admin_alert
|
||||||
from app.services.planner.generate import generate_meal_plan
|
from app.services.planner.generate import generate_meal_plan
|
||||||
from app.services.scraper_service import ScraperService
|
from app.services.scraper_service import ScraperService
|
||||||
|
from app.utils.units import convert_qty
|
||||||
|
|
||||||
from app.services.feedback_analyzer import FeedbackAnalyzer
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
||||||
from app.services.recipe_discovery import RecipeDiscoveryService
|
from app.services.recipe_discovery import RecipeDiscoveryService
|
||||||
@@ -254,7 +255,16 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if match and match.grocery_item and match.grocery_item.current_price:
|
if match and match.grocery_item and match.grocery_item.current_price:
|
||||||
est_cost_total += float(match.grocery_item.current_price)
|
qty = ing.get("qty", 1.0)
|
||||||
|
unit = ing.get("unit", "")
|
||||||
|
gunit = match.grocery_item.unit or ""
|
||||||
|
converted = convert_qty(
|
||||||
|
qty,
|
||||||
|
unit,
|
||||||
|
gunit,
|
||||||
|
ingredient_name_lower=ing_name,
|
||||||
|
)
|
||||||
|
est_cost_total += float(match.grocery_item.current_price) * float(converted)
|
||||||
|
|
||||||
recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
|
recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
|
||||||
est_cost_per_serving = est_cost_total / recipe_servings
|
est_cost_per_serving = est_cost_total / recipe_servings
|
||||||
@@ -408,10 +418,20 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if match and match.grocery_item:
|
if match and match.grocery_item:
|
||||||
lucky_name = html.escape(match.grocery_item.name or "")
|
g_item = match.grocery_item
|
||||||
price = float(match.grocery_item.current_price or 0)
|
lucky_name = html.escape(g_item.name or "")
|
||||||
total_cost += price
|
price = float(g_item.current_price or 0)
|
||||||
price_str = f"${price:.2f}"
|
qty = ing.get("qty", 1.0)
|
||||||
|
unit = ing.get("unit", "")
|
||||||
|
gunit = g_item.unit or ""
|
||||||
|
converted = convert_qty(
|
||||||
|
qty,
|
||||||
|
unit,
|
||||||
|
gunit,
|
||||||
|
ingredient_name_lower=ing_name,
|
||||||
|
)
|
||||||
|
total_cost += price * float(converted)
|
||||||
|
price_str = f"${price * float(converted):.2f}"
|
||||||
else:
|
else:
|
||||||
lucky_name = "—"
|
lucky_name = "—"
|
||||||
price_str = "—"
|
price_str = "—"
|
||||||
|
|||||||
@@ -4,27 +4,24 @@ Inputs:
|
|||||||
ingredients: list[dict] from recipe.ingredients JSONB
|
ingredients: list[dict] from recipe.ingredients JSONB
|
||||||
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
|
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
|
||||||
pantry_ingredient_ids: set of ingredient_ids in home_pantry
|
pantry_ingredient_ids: set of ingredient_ids in home_pantry
|
||||||
|
ingredient_name_index: optional dict[ingredient_id, name_lower] for unit conversion
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
For each ingredient in the recipe, take the top-confidence match
|
For each ingredient in the recipe, take the top-confidence match
|
||||||
(or skip if none). Cost = current_price * qty (best-effort scaling
|
(or skip if none). Cost = current_price × converted_qty.
|
||||||
that ignores unit conversion — see Limitations below).
|
Unit conversion is attempted via app.utils.units.convert_qty; if
|
||||||
Savings = max(regular - current, 0) * qty.
|
the units are incommensurable, falls back to dimensionless qty to
|
||||||
|
preserve the monotonic ranking signal.
|
||||||
Limitations:
|
Savings = max(regular - current, 0) × converted_qty.
|
||||||
Unit conversion (lb vs oz, cup vs ml) is NOT implemented in this
|
|
||||||
pass. The qty multiplier is treated as dimensionless. This produces
|
|
||||||
a biased-but-monotonic ranking signal: recipes that use more of an
|
|
||||||
expensive ingredient still rank as more expensive, which is what
|
|
||||||
the planner needs. Real dollar accuracy can come later.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Dict, Iterable, List, Set
|
from typing import Dict, Iterable, List, Optional, Set
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.services.planner.types import IngredientCost, RecipeCost
|
from app.services.planner.types import IngredientCost, RecipeCost
|
||||||
|
from app.utils.units import convert_qty
|
||||||
|
|
||||||
|
|
||||||
def _decimal(v) -> Decimal:
|
def _decimal(v) -> Decimal:
|
||||||
@@ -87,8 +84,16 @@ def compute_recipe_cost(
|
|||||||
current = _decimal(best.get("current_price"))
|
current = _decimal(best.get("current_price"))
|
||||||
regular = _decimal(best.get("regular_price"))
|
regular = _decimal(best.get("regular_price"))
|
||||||
is_on_sale = bool(best.get("is_on_sale"))
|
is_on_sale = bool(best.get("is_on_sale"))
|
||||||
line_cost = _scale(current, qty)
|
converted_qty = float(
|
||||||
line_savings = _scale(max(regular - current, Decimal("0")), qty)
|
convert_qty(
|
||||||
|
qty,
|
||||||
|
unit,
|
||||||
|
best.get("grocery_unit"),
|
||||||
|
ingredient_name_lower=best.get("ingredient_name"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
line_cost = _scale(current, converted_qty)
|
||||||
|
line_savings = _scale(max(regular - current, Decimal("0")), converted_qty)
|
||||||
|
|
||||||
matched_count += 1
|
matched_count += 1
|
||||||
if is_on_sale:
|
if is_on_sale:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models import (
|
|||||||
FamilyProfile,
|
FamilyProfile,
|
||||||
GroceryItem,
|
GroceryItem,
|
||||||
HomePantry,
|
HomePantry,
|
||||||
|
Ingredient,
|
||||||
IngredientGroceryMatch,
|
IngredientGroceryMatch,
|
||||||
MealPlan,
|
MealPlan,
|
||||||
MealPlanItem,
|
MealPlanItem,
|
||||||
@@ -32,13 +33,14 @@ from app.services.planner.types import GenerationResult
|
|||||||
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
||||||
"""ingredient_id → list of match dicts ordered by confidence DESC."""
|
"""ingredient_id → list of match dicts ordered by confidence DESC."""
|
||||||
rows = (
|
rows = (
|
||||||
db.query(IngredientGroceryMatch, GroceryItem)
|
db.query(IngredientGroceryMatch, GroceryItem, Ingredient)
|
||||||
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
|
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
|
||||||
|
.join(Ingredient, Ingredient.id == IngredientGroceryMatch.ingredient_id)
|
||||||
.order_by(IngredientGroceryMatch.confidence.desc())
|
.order_by(IngredientGroceryMatch.confidence.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
index: Dict[UUID, List[dict]] = {}
|
index: Dict[UUID, List[dict]] = {}
|
||||||
for match, grocery in rows:
|
for match, grocery, ingredient in rows:
|
||||||
index.setdefault(match.ingredient_id, []).append(
|
index.setdefault(match.ingredient_id, []).append(
|
||||||
{
|
{
|
||||||
"grocery_item_id": grocery.id,
|
"grocery_item_id": grocery.id,
|
||||||
@@ -47,6 +49,8 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
|||||||
"regular_price": grocery.regular_price,
|
"regular_price": grocery.regular_price,
|
||||||
"is_on_sale": bool(grocery.is_on_sale),
|
"is_on_sale": bool(grocery.is_on_sale),
|
||||||
"confidence": match.confidence,
|
"confidence": match.confidence,
|
||||||
|
"ingredient_name": ingredient.name.lower(),
|
||||||
|
"grocery_unit": grocery.unit,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return index
|
return index
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Unit conversion between recipe quantities and grocery store prices.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
1. Unit normalization (synonyms → canonical)
|
||||||
|
2. Within-family linear conversion (lb ↔ oz, cup ↔ tbsp, etc.)
|
||||||
|
3. Cross-family conversion via ingredient density lookups
|
||||||
|
4. Fallback to dimensionless qty if conversion is impossible
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Normalization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_NORMALIZE: Dict[str, str] = {
|
||||||
|
# Weight
|
||||||
|
"pound": "lb",
|
||||||
|
"pounds": "lb",
|
||||||
|
"lbs": "lb",
|
||||||
|
"ounce": "oz",
|
||||||
|
"ounces": "oz",
|
||||||
|
"gram": "g",
|
||||||
|
"grams": "g",
|
||||||
|
"kg": "g",
|
||||||
|
"kilogram": "g",
|
||||||
|
"kilograms": "g",
|
||||||
|
# Volume
|
||||||
|
"cups": "cup",
|
||||||
|
"tablespoon": "tbsp",
|
||||||
|
"tablespoons": "tbsp",
|
||||||
|
"tbs": "tbsp",
|
||||||
|
"teaspoon": "tsp",
|
||||||
|
"teaspoons": "tsp",
|
||||||
|
"gallon": "gal",
|
||||||
|
"gallons": "gal",
|
||||||
|
"pint": "pt",
|
||||||
|
"pints": "pt",
|
||||||
|
"quart": "qt",
|
||||||
|
"quarts": "qt",
|
||||||
|
"fluid ounce": "fl oz",
|
||||||
|
"fluid ounces": "fl oz",
|
||||||
|
"fl oz": "fl oz",
|
||||||
|
"milliliter": "ml",
|
||||||
|
"milliliters": "ml",
|
||||||
|
"liter": "l",
|
||||||
|
"liters": "l",
|
||||||
|
# Count / discrete
|
||||||
|
"each": "ea",
|
||||||
|
"piece": "ea",
|
||||||
|
"pieces": "ea",
|
||||||
|
"item": "ea",
|
||||||
|
"items": "ea",
|
||||||
|
"doz": "dozen",
|
||||||
|
"dozen": "dozen",
|
||||||
|
# Container / pack
|
||||||
|
"jar": "jar",
|
||||||
|
"pack": "pack",
|
||||||
|
"container": "container",
|
||||||
|
"carton": "carton",
|
||||||
|
"bag": "bag",
|
||||||
|
"loaf": "loaf",
|
||||||
|
"head": "head",
|
||||||
|
"bunch": "bunch",
|
||||||
|
"stalk": "stalk",
|
||||||
|
"clove": "clove",
|
||||||
|
"can": "can",
|
||||||
|
# Leave these alone
|
||||||
|
"lb": "lb",
|
||||||
|
"oz": "oz",
|
||||||
|
"g": "g",
|
||||||
|
"cup": "cup",
|
||||||
|
"tbsp": "tbsp",
|
||||||
|
"tsp": "tsp",
|
||||||
|
"ea": "ea",
|
||||||
|
"dozen": "dozen",
|
||||||
|
"gal": "gal",
|
||||||
|
"pt": "pt",
|
||||||
|
"qt": "qt",
|
||||||
|
"ml": "ml",
|
||||||
|
"l": "l",
|
||||||
|
"fl oz": "fl oz",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Linear conversion tables (canonical unit → factor to base unit in family)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_WEIGHT = {"lb": Decimal("1"), "oz": Decimal("0.0625"), "g": Decimal("0.00220462")}
|
||||||
|
_VOLUME = {
|
||||||
|
"cup": Decimal("1"),
|
||||||
|
"tbsp": Decimal("0.0625"),
|
||||||
|
"tsp": Decimal("0.0208333"),
|
||||||
|
"gal": Decimal("16"),
|
||||||
|
"qt": Decimal("4"),
|
||||||
|
"pt": Decimal("2"),
|
||||||
|
"fl oz": Decimal("0.125"),
|
||||||
|
"ml": Decimal("0.00422675"),
|
||||||
|
"l": Decimal("4.22675"),
|
||||||
|
}
|
||||||
|
_COUNT = {"ea": Decimal("1"), "dozen": Decimal("12")}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ingredient density table (grams per canonical unit used in recipes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_DENSITY_G_PER_UNIT: Dict[str, Dict[str, Decimal]] = {
|
||||||
|
# ingredient_name_lower → {canonical_unit: grams_per_unit}
|
||||||
|
"olive oil": {"tbsp": Decimal("13.5"), "cup": Decimal("216")},
|
||||||
|
"butter, unsalted": {"tbsp": Decimal("14.2"), "cup": Decimal("227")},
|
||||||
|
"greek yogurt, plain": {"cup": Decimal("227")},
|
||||||
|
"milk, whole": {"cup": Decimal("244")},
|
||||||
|
"sour cream": {"cup": Decimal("230")},
|
||||||
|
"chicken broth": {"cup": Decimal("240")},
|
||||||
|
"rice, long-grain white": {"cup": Decimal("185")},
|
||||||
|
"pasta, penne": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
|
||||||
|
"pasta, spaghetti": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
|
||||||
|
"salt, kosher": {"tsp": Decimal("6"), "tbsp": Decimal("18")},
|
||||||
|
"black pepper": {"tsp": Decimal("2.3")},
|
||||||
|
"cumin, ground": {"tsp": Decimal("2.1")},
|
||||||
|
"italian seasoning": {"tsp": Decimal("1.5")},
|
||||||
|
"curry powder": {"tsp": Decimal("2.4")},
|
||||||
|
"paprika, smoked": {"tsp": Decimal("2.3")},
|
||||||
|
"soy sauce": {"tbsp": Decimal("16"), "cup": Decimal("256")},
|
||||||
|
"salsa, jarred": {"tbsp": Decimal("16")},
|
||||||
|
"cheddar cheese, sharp": {"oz": Decimal("28.35")},
|
||||||
|
"mozzarella, shredded": {"oz": Decimal("28.35")},
|
||||||
|
"parmesan, grated": {"tbsp": Decimal("5")},
|
||||||
|
"coconut milk, canned": {"can": Decimal("400"), "cup": Decimal("240")},
|
||||||
|
"black beans, canned": {"can": Decimal("439"), "cup": Decimal("240")},
|
||||||
|
"chickpeas, canned": {"can": Decimal("439"), "cup": Decimal("240")},
|
||||||
|
"diced tomatoes, canned": {"can": Decimal("411"), "cup": Decimal("240")},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallbacks: when an ingredient isn't in density table, but we know typical
|
||||||
|
# pack sizes from seed data (makes approximate cross-unit possible)
|
||||||
|
_SEED_SIZE_G: Dict[str, Decimal] = {
|
||||||
|
"eggs, large": Decimal("50"),
|
||||||
|
"avocado": Decimal("170"),
|
||||||
|
"lemon": Decimal("58"),
|
||||||
|
"lime": Decimal("44"),
|
||||||
|
"tomato, roma": Decimal("62"),
|
||||||
|
"yellow onion": Decimal("110"),
|
||||||
|
"red bell pepper": Decimal("119"),
|
||||||
|
"green bell pepper": Decimal("109"),
|
||||||
|
"sweet potato": Decimal("114"),
|
||||||
|
"zucchini": Decimal("196"),
|
||||||
|
"carrot": Decimal("61"),
|
||||||
|
"broccoli": Decimal("454"),
|
||||||
|
"spinach, fresh": Decimal("28.35"), # per oz
|
||||||
|
"cilantro": Decimal("15"),
|
||||||
|
"parsley, italian": Decimal("15"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConverter:
|
||||||
|
"""Convert recipe qty+unit to a store price unit (dimensionless fallback)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Build fast lookup: alias → density row
|
||||||
|
self._density: Dict[str, Dict[str, Decimal]] = {}
|
||||||
|
for name, dmap in _DENSITY_G_PER_UNIT.items():
|
||||||
|
entry: Dict[str, Decimal] = dict(dmap)
|
||||||
|
# Standard weight units are always grams-per-weight-unit
|
||||||
|
entry.setdefault("lb", Decimal("453.592"))
|
||||||
|
entry.setdefault("oz", Decimal("28.3495"))
|
||||||
|
entry.setdefault("g", Decimal("1"))
|
||||||
|
self._density[name] = entry
|
||||||
|
for alias in name.split(", "):
|
||||||
|
self._density.setdefault(alias, entry)
|
||||||
|
for name, grams in _SEED_SIZE_G.items():
|
||||||
|
entry = self._density.setdefault(name, {})
|
||||||
|
entry.setdefault("ea", grams)
|
||||||
|
entry.setdefault("lb", Decimal("453.592"))
|
||||||
|
entry.setdefault("oz", Decimal("28.3495"))
|
||||||
|
entry.setdefault("g", Decimal("1"))
|
||||||
|
for alias in name.split(", "):
|
||||||
|
self._density.setdefault(alias, entry)
|
||||||
|
|
||||||
|
# -- public API ---------------------------------------------------------
|
||||||
|
|
||||||
|
def normalize(self, unit: Optional[str]) -> str:
|
||||||
|
"""Return canonical unit string or 'unknown'."""
|
||||||
|
if not unit:
|
||||||
|
return "unknown"
|
||||||
|
s = unit.strip().lower()
|
||||||
|
# Exact match first
|
||||||
|
canon = _NORMALIZE.get(s)
|
||||||
|
if canon:
|
||||||
|
return canon
|
||||||
|
# De-pluralize fallback
|
||||||
|
return _NORMALIZE.get(s.rstrip("s"), s)
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
qty: float,
|
||||||
|
from_unit: Optional[str],
|
||||||
|
to_unit: Optional[str],
|
||||||
|
ingredient_name_lower: Optional[str] = None,
|
||||||
|
) -> Decimal:
|
||||||
|
"""Return a dimensionless scalar to multiply store price by.
|
||||||
|
|
||||||
|
If conversion is impossible, returns the original qty unchanged
|
||||||
|
(preserves the old monotonic ranking signal).
|
||||||
|
"""
|
||||||
|
if not from_unit or not to_unit:
|
||||||
|
return Decimal(str(qty))
|
||||||
|
|
||||||
|
fu = self.normalize(from_unit)
|
||||||
|
tu = self.normalize(to_unit)
|
||||||
|
|
||||||
|
if fu == tu:
|
||||||
|
return Decimal(str(qty))
|
||||||
|
|
||||||
|
# 1. Same family → linear ratio
|
||||||
|
ratio = self._same_family_ratio(fu, tu)
|
||||||
|
if ratio is not None:
|
||||||
|
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
|
||||||
|
|
||||||
|
# 2. Cross-family with density
|
||||||
|
if ingredient_name_lower:
|
||||||
|
ratio = self._density_ratio(fu, tu, ingredient_name_lower)
|
||||||
|
if ratio is not None:
|
||||||
|
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
|
||||||
|
|
||||||
|
# 3. Fallback: treat as dimensionless
|
||||||
|
return Decimal(str(qty))
|
||||||
|
|
||||||
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _same_family_ratio(self, fu: str, tu: str) -> Optional[Decimal]:
|
||||||
|
for family in (_WEIGHT, _VOLUME, _COUNT):
|
||||||
|
if fu in family and tu in family:
|
||||||
|
base_fu = family[fu]
|
||||||
|
base_tu = family[tu]
|
||||||
|
# fu →* base, tu →* base → qty_fu × (base_fu / base_tu) = qty_tu
|
||||||
|
return base_fu / base_tu
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _density_ratio(
|
||||||
|
self,
|
||||||
|
fu: str,
|
||||||
|
tu: str,
|
||||||
|
ing_name: str,
|
||||||
|
) -> Optional[Decimal]:
|
||||||
|
dmap = self._density.get(ing_name.lower())
|
||||||
|
if not dmap:
|
||||||
|
return None
|
||||||
|
g_fu = dmap.get(fu)
|
||||||
|
g_tu = dmap.get(tu)
|
||||||
|
if g_fu is None or g_tu is None:
|
||||||
|
return None
|
||||||
|
# qty_fu × g_fu grams = qty_tu × g_tu grams
|
||||||
|
# → qty_tu = qty_fu × g_fu / g_tu
|
||||||
|
return g_fu / g_tu
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton for convenience
|
||||||
|
_DEFAULT_CONVERTER = UnitConverter()
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience free functions so callers don't need an instance
|
||||||
|
|
||||||
|
def normalize_unit(unit: Optional[str]) -> str:
|
||||||
|
return _DEFAULT_CONVERTER.normalize(unit)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_qty(
|
||||||
|
qty: float,
|
||||||
|
from_unit: Optional[str],
|
||||||
|
to_unit: Optional[str],
|
||||||
|
ingredient_name_lower: Optional[str] = None,
|
||||||
|
) -> Decimal:
|
||||||
|
return _DEFAULT_CONVERTER.convert(qty, from_unit, to_unit, ingredient_name_lower)
|
||||||
@@ -15,10 +15,12 @@ def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9):
|
|||||||
return {
|
return {
|
||||||
"grocery_item_id": grocery_id,
|
"grocery_item_id": grocery_id,
|
||||||
"grocery_item_name": name,
|
"grocery_item_name": name,
|
||||||
|
"ingredient_name": name.lower(),
|
||||||
"current_price": Decimal(str(current)),
|
"current_price": Decimal(str(current)),
|
||||||
"regular_price": Decimal(str(regular)),
|
"regular_price": Decimal(str(regular)),
|
||||||
"is_on_sale": is_on_sale,
|
"is_on_sale": is_on_sale,
|
||||||
"confidence": Decimal(str(confidence)),
|
"confidence": Decimal(str(confidence)),
|
||||||
|
"grocery_unit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for app.utils.units."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.utils.units import UnitConverter, convert_qty, normalize_unit
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeUnit:
|
||||||
|
def test_synonyms(self):
|
||||||
|
assert normalize_unit("pounds") == "lb"
|
||||||
|
assert normalize_unit("OUNCE") == "oz"
|
||||||
|
assert normalize_unit("Cups") == "cup"
|
||||||
|
assert normalize_unit("TBS") == "tbsp"
|
||||||
|
assert normalize_unit("each") == "ea"
|
||||||
|
assert normalize_unit("doz") == "dozen"
|
||||||
|
|
||||||
|
def test_already_canonical(self):
|
||||||
|
assert normalize_unit("lb") == "lb"
|
||||||
|
assert normalize_unit("cup") == "cup"
|
||||||
|
assert normalize_unit("ea") == "ea"
|
||||||
|
|
||||||
|
def test_unknown_passthrough(self):
|
||||||
|
assert normalize_unit("bunch") == "bunch"
|
||||||
|
assert normalize_unit("can") == "can"
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert normalize_unit(None) == "unknown"
|
||||||
|
assert normalize_unit("") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithinFamilyConversion:
|
||||||
|
def test_weight_lb_to_oz(self):
|
||||||
|
assert convert_qty(2.0, "lb", "oz") == Decimal("32")
|
||||||
|
|
||||||
|
def test_weight_oz_to_lb(self):
|
||||||
|
result = convert_qty(16.0, "oz", "lb")
|
||||||
|
assert result == Decimal("1.0000")
|
||||||
|
|
||||||
|
def test_volume_cup_to_tbsp(self):
|
||||||
|
result = convert_qty(1.0, "cup", "tbsp")
|
||||||
|
assert result == Decimal("16")
|
||||||
|
|
||||||
|
def test_volume_tbsp_to_tsp(self):
|
||||||
|
result = convert_qty(1.0, "tbsp", "tsp")
|
||||||
|
assert result == Decimal("3")
|
||||||
|
|
||||||
|
def test_count_dozen_to_ea(self):
|
||||||
|
result = convert_qty(1.0, "dozen", "ea")
|
||||||
|
assert result == Decimal("12")
|
||||||
|
|
||||||
|
def test_same_unit(self):
|
||||||
|
assert convert_qty(5.0, "lb", "lb") == Decimal("5")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDensityConversion:
|
||||||
|
def test_olive_oil_tbsp_to_cup(self):
|
||||||
|
# 1 cup = 16 tbsp
|
||||||
|
result = convert_qty(
|
||||||
|
1.0, "cup", "tbsp", ingredient_name_lower="olive oil"
|
||||||
|
)
|
||||||
|
assert result == Decimal("16")
|
||||||
|
|
||||||
|
def test_rice_cup_to_lb(self):
|
||||||
|
# 1 cup rice = 185 g; 1 lb = 453.592 g → 1 cup = 185/453.592 ≈ 0.4079 lb
|
||||||
|
result = convert_qty(
|
||||||
|
1.0, "cup", "lb", ingredient_name_lower="rice, long-grain white"
|
||||||
|
)
|
||||||
|
assert result == Decimal("0.4079")
|
||||||
|
|
||||||
|
def test_butter_tbsp_to_cup(self):
|
||||||
|
result = convert_qty(
|
||||||
|
1.0, "cup", "tbsp", ingredient_name_lower="butter, unsalted"
|
||||||
|
)
|
||||||
|
assert result == Decimal("16")
|
||||||
|
|
||||||
|
def test_seed_size_ea_to_lb(self):
|
||||||
|
# 1 avocado ≈ 170 g = 0.3748 lb
|
||||||
|
result = convert_qty(
|
||||||
|
1.0, "ea", "lb", ingredient_name_lower="avocado"
|
||||||
|
)
|
||||||
|
assert result == Decimal("0.3748")
|
||||||
|
|
||||||
|
def test_unknown_density_falls_back(self):
|
||||||
|
# No density for "dragon fruit" → fallback to dimensionless qty
|
||||||
|
result = convert_qty(2.0, "cup", "lb", ingredient_name_lower="dragon fruit")
|
||||||
|
assert result == Decimal("2")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFallback:
|
||||||
|
def test_incommensurable_units_without_name(self):
|
||||||
|
result = convert_qty(2.0, "cup", "lb")
|
||||||
|
assert result == Decimal("2")
|
||||||
|
|
||||||
|
def test_incommensurable_units_with_unmapped_name(self):
|
||||||
|
result = convert_qty(2.0, "cup", "lb", ingredient_name_lower="xyz")
|
||||||
|
assert result == Decimal("2")
|
||||||
|
|
||||||
|
def test_missing_from_unit(self):
|
||||||
|
result = convert_qty(2.0, None, "lb")
|
||||||
|
assert result == Decimal("2")
|
||||||
|
|
||||||
|
def test_missing_to_unit(self):
|
||||||
|
result = convert_qty(2.0, "lb", None)
|
||||||
|
assert result == Decimal("2")
|
||||||
Reference in New Issue
Block a user