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