cauldron/tests/test_aggregator.py
Kayos cc6222139d v0.3 step 2: density-table aggregator engine — the killer math
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
2026-04-28 22:14:01 -07:00

234 lines
9.3 KiB
Python
Raw Permalink 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.

"""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()