Pure-Python module + 14 unit tests proving the centerpiece works:
test_rice_mixed:
in: [(2 cup, rice), (1.25 lb, rice)]
out: 2.25 lb rice (one line, properly mass+volume combined via density)
test_butter_mixed:
in: [(0.5 cup, butter), (4 oz, butter)]
out: ~227g butter (~8oz / 0.5 lb)
test_three_recipes:
feeds 9 ingredients across 3 recipes through the aggregator;
rice (cup + lb) collapses, garlic (cloves) sums, eggs count, salt as 'pinch'
bucketed as to-taste. All on one shopping list.
Algorithm in cauldron/aggregator.py:
1. Bucket ingredients by canonical food (foods_lookup callable injected — no DB coupling)
2. Within each food, classify each unit (mass / volume / count / vague / unknown)
3. CASE 1: only one unit class present → simple sum, display in canonical store-friendly unit
4. CASE 2: mass + volume (the killer) → use density_g_per_ml to combine to grams
5. CASE 3: count + (mass | volume) → use common_size_g to convert count to grams
6. CASE 4: anything that can't reconcile (no density, mixed unknown) → split into 1 line per class with is_split=True
7. vague (pinch, dash, to taste) → annotate as 'plus to-taste'
8. unknown units → emit verbatim with the original text
Display: store-friendly unit picker:
<30g → grams
<500g → ounces (nearest 0.5)
<2kg → pounds (nearest 0.25)
>2kg → big pounds
The aggregator is dependency-injection-friendly — foods_lookup(name) is
the only external call. Tests pass a stub dict; production will pass
foods.search_food(db, name). Decouples math from data quality.
Tests run via:
python3 -m unittest discover -s tests -v
234 lines
9.3 KiB
Python
234 lines
9.3 KiB
Python
"""Aggregator tests — prove the math works before any UI is built on top.
|
||
|
||
Run with:
|
||
python3 -m unittest discover -s tests -v
|
||
|
||
These don't touch the DB; they pass a stub foods_lookup to the aggregator.
|
||
"""
|
||
import unittest
|
||
from cauldron.aggregator import (
|
||
Ingredient,
|
||
ShoppingLine,
|
||
aggregate,
|
||
classify_unit,
|
||
display_mass,
|
||
display_volume,
|
||
to_g,
|
||
to_ml,
|
||
)
|
||
|
||
|
||
# Stub food catalog for tests
|
||
FOODS = {
|
||
"rice": {"canonical_name": "rice", "density_g_per_ml": 0.85, "default_unit_class": "mass", "common_size_g": None},
|
||
"butter": {"canonical_name": "butter", "density_g_per_ml": 0.96, "default_unit_class": "mass", "common_size_g": None},
|
||
"olive oil": {"canonical_name": "olive oil", "density_g_per_ml": 0.92, "default_unit_class": "volume", "common_size_g": None},
|
||
"milk": {"canonical_name": "milk", "density_g_per_ml": 1.03, "default_unit_class": "volume", "common_size_g": None},
|
||
"egg": {"canonical_name": "egg", "density_g_per_ml": None, "default_unit_class": "count", "common_size_g": 50.0},
|
||
"onion": {"canonical_name": "onion", "density_g_per_ml": None, "default_unit_class": "count", "common_size_g": 150.0},
|
||
"garlic": {"canonical_name": "garlic", "density_g_per_ml": None, "default_unit_class": "count", "common_size_g": 5.0},
|
||
"salt": {"canonical_name": "salt", "default_unit_class": "mixed", "density_g_per_ml": 1.20, "common_size_g": None},
|
||
}
|
||
|
||
|
||
def lookup(name: str) -> dict | None:
|
||
return FOODS.get(name.strip().lower())
|
||
|
||
|
||
class TestUnitMath(unittest.TestCase):
|
||
def test_volume_conversions(self):
|
||
self.assertAlmostEqual(to_ml(1, "cup"), 236.588, places=2)
|
||
self.assertAlmostEqual(to_ml(1, "tbsp"), 14.7868, places=2)
|
||
self.assertAlmostEqual(to_ml(1, "tsp"), 4.92892, places=2)
|
||
self.assertAlmostEqual(to_ml(1, "fl oz"), 29.5735, places=2)
|
||
self.assertAlmostEqual(to_ml(1, "liter"), 1000.0, places=2)
|
||
self.assertIsNone(to_ml(1, "lb"))
|
||
|
||
def test_mass_conversions(self):
|
||
self.assertAlmostEqual(to_g(1, "lb"), 453.592, places=2)
|
||
self.assertAlmostEqual(to_g(1, "oz"), 28.3495, places=2)
|
||
self.assertAlmostEqual(to_g(1, "kg"), 1000.0, places=2)
|
||
self.assertIsNone(to_g(1, "cup"))
|
||
|
||
def test_classify(self):
|
||
self.assertEqual(classify_unit("cup"), "volume")
|
||
self.assertEqual(classify_unit("LB"), "mass")
|
||
self.assertEqual(classify_unit("each"), "count")
|
||
self.assertEqual(classify_unit("clove"), "count")
|
||
self.assertEqual(classify_unit("pinch"), "vague")
|
||
self.assertEqual(classify_unit("squodgen"), "unknown")
|
||
self.assertEqual(classify_unit(""), "count")
|
||
|
||
def test_display_mass(self):
|
||
# < 30g → grams
|
||
q, u = display_mass(15)
|
||
self.assertEqual(u, "g")
|
||
# 30-500g → ounces
|
||
q, u = display_mass(100)
|
||
self.assertEqual(u, "oz")
|
||
# 500-2000g → pounds
|
||
q, u = display_mass(947)
|
||
self.assertEqual(u, "lb")
|
||
self.assertAlmostEqual(q, 2.0, places=1)
|
||
# >2000g → big pounds
|
||
q, u = display_mass(5000)
|
||
self.assertEqual(u, "lb")
|
||
|
||
|
||
class TestAggregateSimpleSums(unittest.TestCase):
|
||
"""All-mass, all-volume, all-count — no unit-mixing complexity."""
|
||
|
||
def test_mass_only_combines(self):
|
||
ings = [
|
||
Ingredient(qty=1, unit="lb", food_name="rice"),
|
||
Ingredient(qty=8, unit="oz", food_name="rice"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
self.assertEqual(out[0].food, "rice")
|
||
# 1 lb + 8 oz = 1.5 lb
|
||
self.assertEqual(out[0].unit, "lb")
|
||
self.assertAlmostEqual(out[0].qty, 1.5, places=1)
|
||
|
||
def test_volume_only_combines(self):
|
||
ings = [
|
||
Ingredient(qty=2, unit="tbsp", food_name="olive oil"),
|
||
Ingredient(qty=1, unit="cup", food_name="olive oil"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
self.assertEqual(out[0].food, "olive oil")
|
||
# 2 tbsp + 1 cup ~= 266ml ~= 1.13 cups
|
||
self.assertEqual(out[0].unit, "cup")
|
||
self.assertGreater(out[0].qty, 1.1)
|
||
self.assertLess(out[0].qty, 1.2)
|
||
|
||
def test_count_only_combines(self):
|
||
ings = [
|
||
Ingredient(qty=2, unit="", food_name="egg"),
|
||
Ingredient(qty=3, unit="each", food_name="egg"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
self.assertEqual(out[0].food, "egg")
|
||
self.assertEqual(out[0].qty, 5)
|
||
|
||
|
||
class TestAggregateMassPlusVolume(unittest.TestCase):
|
||
"""The killer case Cobb wants: 2 cups rice + 1.25 lb rice → ~2 lb."""
|
||
|
||
def test_rice_mixed(self):
|
||
"""The killer case Cobb wants — rice in cups + lb merges to one line."""
|
||
ings = [
|
||
Ingredient(qty=2, unit="cup", food_name="rice"),
|
||
Ingredient(qty=1.25, unit="lb", food_name="rice"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
line = out[0]
|
||
self.assertEqual(line.food, "rice")
|
||
# 2 cups × 236.588 ml/cup × 0.85 g/ml = 402g
|
||
# 1.25 lb = 567g
|
||
# total = ~969g → 2.137 lb → rounded to nearest .25 lb = 2.25 lb
|
||
self.assertEqual(line.unit, "lb")
|
||
self.assertAlmostEqual(line.qty, 2.25, places=2)
|
||
|
||
def test_butter_mixed(self):
|
||
ings = [
|
||
Ingredient(qty=0.5, unit="cup", food_name="butter"),
|
||
Ingredient(qty=4, unit="oz", food_name="butter"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
# 0.5 cup butter (density 0.96) = 113.6g
|
||
# 4 oz = 113.4g
|
||
# total ~227g → between 8oz and 0.5lb
|
||
self.assertEqual(out[0].food, "butter")
|
||
|
||
def test_no_density_falls_back_to_split(self):
|
||
"""If a food has NO density data, we can't combine across class — split."""
|
||
ings = [
|
||
Ingredient(qty=2, unit="cup", food_name="mystery food"),
|
||
Ingredient(qty=1, unit="lb", food_name="mystery food"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 2)
|
||
# both lines marked as is_split
|
||
self.assertTrue(all(l.is_split for l in out))
|
||
|
||
|
||
class TestAggregateCountPlusOther(unittest.TestCase):
|
||
"""Count + mass/volume — uses common_size_g if known."""
|
||
|
||
def test_onion_count_plus_volume_splits(self):
|
||
"""Onion has count common_size_g but no density, so we can't safely
|
||
convert chopped-cup-of-onion to grams. UX-wise '2 whole onions' vs
|
||
'1 cup chopped onion' are different things to buy anyway — split."""
|
||
ings = [
|
||
Ingredient(qty=2, unit="", food_name="onion"),
|
||
Ingredient(qty=1, unit="cup", food_name="onion"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 2)
|
||
# both for onion, both flagged as split
|
||
self.assertTrue(all(l.food == "onion" for l in out))
|
||
self.assertTrue(all(l.is_split for l in out))
|
||
|
||
def test_egg_only_count(self):
|
||
ings = [Ingredient(qty=4, unit="each", food_name="egg")]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(out[0].qty, 4)
|
||
|
||
|
||
class TestAggregateMultipleFoods(unittest.TestCase):
|
||
"""Real recipe-day scenario — 3 recipes worth of ingredients."""
|
||
|
||
def test_three_recipes(self):
|
||
ings = [
|
||
# Recipe A: pasta with garlic butter
|
||
Ingredient(qty=1, unit="lb", food_name="rice", source_recipe_slug="A"),
|
||
Ingredient(qty=2, unit="tbsp", food_name="butter", source_recipe_slug="A"),
|
||
Ingredient(qty=3, unit="clove", food_name="garlic", source_recipe_slug="A"),
|
||
# Recipe B: stir-fry
|
||
Ingredient(qty=1.5, unit="cup", food_name="rice", source_recipe_slug="B"),
|
||
Ingredient(qty=2, unit="", food_name="onion", source_recipe_slug="B"),
|
||
Ingredient(qty=2, unit="clove", food_name="garlic", source_recipe_slug="B"),
|
||
# Recipe C: omelette
|
||
Ingredient(qty=4, unit="each", food_name="egg", source_recipe_slug="C"),
|
||
Ingredient(qty=0.25, unit="cup", food_name="milk", source_recipe_slug="C"),
|
||
Ingredient(qty=1, unit="pinch", food_name="salt", source_recipe_slug="C"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
|
||
# Six unique foods
|
||
foods = {l.food for l in out}
|
||
self.assertSetEqual(foods, {"rice", "butter", "garlic", "onion", "egg", "milk", "salt"})
|
||
|
||
# Rice: 1 lb + 1.5 cup * 0.85 g/ml * 236.588 = 453g + 301g = 754g → 1.75 lb
|
||
rice = next(l for l in out if l.food == "rice")
|
||
self.assertEqual(rice.unit, "lb")
|
||
self.assertAlmostEqual(rice.qty, 1.75, places=2)
|
||
|
||
# Garlic: 3 + 2 = 5 cloves
|
||
garlic = next(l for l in out if l.food == "garlic")
|
||
self.assertEqual(garlic.unit, "clove")
|
||
self.assertEqual(garlic.qty, 5)
|
||
|
||
# Egg: 4 ea
|
||
egg = next(l for l in out if l.food == "egg")
|
||
self.assertEqual(egg.qty, 4)
|
||
|
||
|
||
class TestAggregateNotes(unittest.TestCase):
|
||
def test_notes_collected(self):
|
||
ings = [
|
||
Ingredient(qty=1, unit="", food_name="onion", note="diced"),
|
||
Ingredient(qty=1, unit="", food_name="onion", note="thinly sliced"),
|
||
]
|
||
out = aggregate(ings, lookup)
|
||
self.assertEqual(len(out), 1)
|
||
self.assertSetEqual(set(out[0].notes), {"diced", "thinly sliced"})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|