""" Walls off the defect where non-food products carried a health score. Measured against the live catalogue on 2026-09-03, these rows had one: Colgate-Palmolive Palmolive Naturals General soap Cavinkare Nyle, Cavinkare Nature's Hair Care shampoo P&G Pantene Hair Care shampoo Godrej Hit Spray Personal Care - Mosquito 291 kcal Every category string in LIVE_CATEGORIES below was read out of the production brand tables, with its row count, so this file tests the taxonomy that exists rather than the one `CATEGORY_REGISTRY` describes - the two have drifted, which is the whole reason `consumability` is not just a field on the registry. Pure unit tests: no database, no network, no LLM. """ from __future__ import annotations import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.services import nutrition_data_service as nds # noqa: E402 from app.services.consumability import ( # noqa: E402 CATEGORY_VERDICTS, Edibility, classify_edibility, is_consumable, is_junk_category, is_non_consumable, ) from app.services.enrichment.hsn_gst.models import HSN_GST_TABLE # noqa: E402 # (category string, live row count, expected verdict). Read from the production # database; the counts are kept so a future reader can tell a 189-row decision # from a 1-row one. LIVE_CATEGORIES = [ ("Dairy", 189, Edibility.CONSUMABLE), ("Beverages", 174, Edibility.CONSUMABLE), ("Spices & Masalas", 136, Edibility.CONSUMABLE), ("Chocolates", 119, Edibility.CONSUMABLE), ("Fruits & Vegetables", 95, Edibility.CONSUMABLE), ("Food - Mixes", 83, Edibility.CONSUMABLE), ("Tea & Coffee", 76, Edibility.CONSUMABLE), ("Hair Care", 51, Edibility.NON_CONSUMABLE), ("Oral Care", 45, Edibility.NON_CONSUMABLE), ("Snacks", 42, Edibility.CONSUMABLE), ("Cooking Oils", 42, Edibility.CONSUMABLE), ("Detergents & Fabric Care", 38, Edibility.NON_CONSUMABLE), ("Pulses, Grains & Spices", 35, Edibility.CONSUMABLE), ("Health Drinks", 26, Edibility.CONSUMABLE), ("Pasta & Noodles", 24, Edibility.CONSUMABLE), ("Pickles & Chutneys", 24, Edibility.CONSUMABLE), ("Skin Care", 19, Edibility.NON_CONSUMABLE), ("Bath Soap", 17, Edibility.NON_CONSUMABLE), ("Fresh Herbs & Greens", 17, Edibility.CONSUMABLE), ("Atta & Staples", 16, Edibility.CONSUMABLE), ("Fish & Seafood", 15, Edibility.CONSUMABLE), ("Flowers", 14, Edibility.NON_CONSUMABLE), ("Candy & Confectionery", 13, Edibility.CONSUMABLE), ("Dairy - Desserts", 13, Edibility.CONSUMABLE), ("Biscuits & Cookies", 12, Edibility.CONSUMABLE), ("Dishwash", 12, Edibility.NON_CONSUMABLE), ("Health Foods", 12, Edibility.CONSUMABLE), ("Food - Spreads", 11, Edibility.CONSUMABLE), ("Skin & Bath Care", 10, Edibility.NON_CONSUMABLE), ("Noodles & Instant Food", 10, Edibility.CONSUMABLE), ("Cheese", 10, Edibility.CONSUMABLE), ("Salt & Staples", 9, Edibility.CONSUMABLE), ("Breakfast Cereal", 8, Edibility.CONSUMABLE), ("Feminine Hygiene", 6, Edibility.NON_CONSUMABLE), ("Ready to Eat", 6, Edibility.CONSUMABLE), ("Dry Fruits & Nuts", 6, Edibility.CONSUMABLE), ("Eggs", 5, Edibility.CONSUMABLE), ("Bakery & Breads", 5, Edibility.CONSUMABLE), ("Ice Cream", 5, Edibility.CONSUMABLE), ("Health Care - Cold & Cough", 5, Edibility.NON_CONSUMABLE), ("Personal Care - Mosquito Repellent", 5, Edibility.NON_CONSUMABLE), ("Food & Beverages", 4, Edibility.CONSUMABLE), ("Men's Grooming", 4, Edibility.NON_CONSUMABLE), ("Household - Lamp Oil", 4, Edibility.NON_CONSUMABLE), ("Fragrance & Deodorants", 3, Edibility.NON_CONSUMABLE), ("Household Cleaning", 2, Edibility.NON_CONSUMABLE), ("Biscuits", 2, Edibility.CONSUMABLE), ("Food - Soups & Sauces", 2, Edibility.CONSUMABLE), ("Crackers", 2, Edibility.CONSUMABLE), ("Health Care - Antiseptic", 2, Edibility.NON_CONSUMABLE), ("Namkeen", 1, Edibility.CONSUMABLE), ("Household - Air Freshener", 1, Edibility.NON_CONSUMABLE), ("Flour & Grains", 1, Edibility.CONSUMABLE), ("Staples", 1, Edibility.CONSUMABLE), ("Personal Care", 1, Edibility.NON_CONSUMABLE), ("Health Care - Digestive", 1, Edibility.NON_CONSUMABLE), ] # The two category values that genuinely carry no signal. "General" holds 40 # rows of mixed food and non-food; "1".."5" are import damage on 17 rows. # Both are meant to reach the title, so they are excluded here rather than # given a verdict this file would have to invent. UNDECIDABLE_CATEGORIES = ("General", "1", "2", "3", "4", "5") # Categories holding both food and non-food, so they carry no verdict of their # own and are excluded from the table above. Found by running the purge audit # before deleting anything - see test_baby_food_is_food. MIXED_CATEGORIES = ("Baby Care", "Health Care - Ayurvedic") @pytest.mark.parametrize("category,count,expected", LIVE_CATEGORIES) def test_every_live_category_has_the_expected_verdict(category, count, expected): verdict = classify_edibility(category) assert verdict.edibility is expected, ( f"{category!r} ({count} live rows) classified {verdict.edibility.value}: " f"{verdict.reason}" ) @pytest.mark.parametrize( "category", [c for c in sorted(HSN_GST_TABLE) if c not in MIXED_CATEGORIES], ) def test_every_hsn_category_resolves(category): """Drift guard. Someone adding a category to the tax table and not here would otherwise get a silent UNKNOWN, and UNKNOWN means no health score. The mixed categories are excluded because their UNKNOWN is the intended answer - they are decided by the title, not by the category. """ assert classify_edibility(category).edibility is not Edibility.UNKNOWN def test_lamp_oil_is_not_a_cooking_oil(): """These must not collapse into each other. `resolve_hsn_gst`'s keyword fallbacks contain a greedy ("oil", "1517") entry, which is chapter 15 - an edible fat. Reading HSN_GST_TABLE directly instead of calling that resolver is what keeps lamp oil inedible. """ assert is_consumable("Cooking Oils", "Idhayam Sesame Oil 500ml") is True assert is_non_consumable("Household - Lamp Oil", "Idhayam Lamp Oil 500ml") is True def test_flowers_are_not_food(): """HSN 0603 is chapter 06, which sits inside the naive 01-24 "food" range. Flowers reach the Own Products table through the same produce lexicon as the vegetables, so nothing upstream separates them - this is the separation.""" assert is_non_consumable("Flowers", "Jasmine Garland") is True assert is_consumable("Flowers", "Jasmine Garland") is False def test_medicines_are_not_scored(): """Ingested, but they publish dosage rather than a nutrition panel.""" for category in ("Health Care - Cold & Cough", "Health Care - Digestive", "Health Care - Antiseptic"): assert is_non_consumable(category) is True, category assert is_non_consumable("Health Care - Cold & Cough", "Dabur Honitus 1L") is True def test_baby_food_is_food(): """The error this classifier made on its first audit run, caught before a single row was deleted. "Baby Care" held 21 live rows and a blanket non-consumable verdict. They are Nestle Cerelac, Nan Pro and Lactogen - infant formula and baby cereal, among the most heavily nutrition-labelled products sold in India - sitting beside a bottle of baby oil. One verdict cannot cover both, so the category defers to the title. """ assert is_consumable("Baby Care", "Nestle Cerelac 125g") is True assert is_consumable("Baby Care", "Nestle Nan Pro 400 g") is True assert is_consumable("Baby Care", "Nestle Lactogen 400 g") is True assert is_non_consumable("Baby Care", "Johnson Baby Soap 100g") is True assert is_non_consumable("Baby Care", "Pampers Diaper Large 20s") is True def test_chyawanprash_is_food_but_cough_syrup_is_not(): """Same shape as Baby Care: "Health Care - Ayurvedic" mixes a spoonable food supplement with medicine, so it too defers to the title.""" assert is_consumable("Health Care - Ayurvedic", "Chyawanprash 100% Natural 70g") is True @pytest.mark.parametrize("category", MIXED_CATEGORIES) def test_a_mixed_category_carries_no_verdict_of_its_own(category): """With no title to go on there is nothing to decide, and UNKNOWN neither enriches nor deletes.""" assert classify_edibility(category).edibility is Edibility.UNKNOWN def test_health_drinks_are_food_despite_the_name(): assert is_consumable("Health Drinks", "Horlicks Classic Malt 500g") is True assert is_consumable("Health Foods", "Manna Health Mix 500g") is True def test_unknown_is_neither_consumable_nor_non_consumable(): """The asymmetry the purge script depends on: an unrecognised product is never enriched AND never deleted. If these two ever become inverses, a classifier miss turns into data loss.""" verdict = classify_edibility("Zzz Unheard Of Category", "Mystery Item 1kg") assert verdict.edibility is Edibility.UNKNOWN assert is_consumable("Zzz Unheard Of Category", "Mystery Item 1kg") is False assert is_non_consumable("Zzz Unheard Of Category", "Mystery Item 1kg") is False def test_a_missing_category_falls_back_to_the_title(): assert is_consumable("", "Amul Butter 500g") is True assert is_consumable(None, "Toor Dhal 1kg") is True assert is_non_consumable("", "Lifebuoy Soap 100g") is True assert classify_edibility(None, "").edibility is Edibility.UNKNOWN @pytest.mark.parametrize("junk", UNDECIDABLE_CATEGORIES) def test_an_uninformative_category_defers_to_the_title(junk): assert is_consumable(junk, "Aachi Sambar Powder 100g") is True assert is_non_consumable(junk, "Lifebuoy Soap 100g") is True def test_numeric_categories_are_reported_as_junk(): """17 live rows carry a bare digit as their category. They are recognised so the purge audit can report them for catalogue repair, rather than being quietly accommodated.""" assert is_junk_category("3") is True assert is_junk_category("Dairy") is False def test_a_toothpaste_brand_is_not_a_chocolate(): """The regression that forced `exact_only` into `detect_category_from_text`. Its fuzzy fallback scores "colgate" at >=0.8 against the misspelling keyword "choclate", so "Colgate-Palmolive Palmolive Naturals" - whose stored category is the uninformative "General" - resolved to Chocolates and would have been handed a health score. """ verdict = classify_edibility("General", "Colgate-Palmolive Palmolive Naturals") assert verdict.edibility is not Edibility.CONSUMABLE def test_soapnut_is_not_a_soap(): """The old gate matched NON_FOOD_KEYWORDS by substring, so "soap" hit *soapnut* (reetha). Matching here is whole-word.""" assert is_non_consumable("Fruits & Vegetables", "Soapnut Reetha 500g") is False @pytest.mark.parametrize("category,title", [ ("Bath Soap", "Colgate-Palmolive Palmolive Naturals"), ("Hair Care", "Cavinkare Nyle"), ("Hair Care", "Cavinkare Nature's"), ("Hair Care", "P G Pg Pantene"), ("Personal Care - Mosquito Repellent", "Godrej Hit Spray"), ]) def test_the_products_that_are_wrongly_scored_today_all_classify_non_food(category, title): """Named individually because each one is a live row with a health score.""" assert is_non_consumable(category, title) is True def test_the_legacy_gate_still_answers(): """`_looks_non_food` is kept as a delegate; nothing outside its module imports it, but silently changing a public-looking name is worse than keeping it honest.""" assert nds._looks_non_food("Lifebuoy Soap", "Bath Soap") is True assert nds._looks_non_food("Amul Butter", "Dairy") is False def test_the_verdict_map_has_no_contradictions(): """A category listed as both consumable and non-consumable would resolve by dict-ordering luck.""" assert len(CATEGORY_VERDICTS) == len(set(CATEGORY_VERDICTS)) assert all(isinstance(v, Edibility) for v in CATEGORY_VERDICTS.values())