""" Day 1 verification tests — checks our lye calculator against HSCG/SoapCalc. Run: python test_chemistry.py All tests should pass before any AI layer is added. """ import sys from chemistry import ( calculate_lye, score_recipe, find_substitutes, cure_timeline, profile_summary, list_oils, OILS ) PASS = "✅" FAIL = "❌" results = [] def check(label: str, condition: bool, detail: str = ""): status = PASS if condition else FAIL results.append((status, label, detail)) print(f"{status} {label}" + (f" — {detail}" if detail else "")) # --------------------------------------------------------------------------- # 1. SAP database sanity checks # --------------------------------------------------------------------------- print("\n── Oil database ──") check("Oil count >= 40", len(OILS) >= 40, f"got {len(OILS)}") check("All oils have SAP range", all( o.naoh_sap_low > 0 and o.naoh_sap_high >= o.naoh_sap_low for o in OILS.values() ), "") check("All SAP averages in realistic range (0.06–0.21)", all( 0.06 <= o.naoh_sap <= 0.21 for o in OILS.values() ), "") check("Vegan flags set correctly", ( not OILS["lard"].vegan and not OILS["tallow (beef)"].vegan and OILS["coconut oil"].vegan and OILS["olive oil"].vegan ), "") check("KOH SAP = NaOH * 1.403 (±0.002)", all( abs(o.koh_sap - round(o.naoh_sap * 1.403, 4)) <= 0.002 for o in OILS.values() ), "") # --------------------------------------------------------------------------- # 2. Lye calculator — test against manually verified recipes # --------------------------------------------------------------------------- print("\n── Lye calculator ──") # Recipe 1: Classic beginner — 500g, 30% coconut / 70% olive, 5% superfat # Expected NaOH: coconut 150g * 0.1915 = 28.73 + olive 350g * 0.135 = 47.25 → 75.98 * 0.95 = ~72.2g recipe1 = {"coconut oil": 150, "olive oil": 350} r1 = calculate_lye(recipe1, lye_type="naoh", superfat=5, water_ratio=0.38) check("Recipe 1: no error", "error" not in r1, "") check("Recipe 1: total oil = 500g", r1["total_oil_weight_g"] == 500, f"got {r1.get('total_oil_weight_g')}") check("Recipe 1: NaOH in expected range (70–76g)", 70 <= r1.get("naoh_g", 0) <= 76, f"got {r1.get('naoh_g')}g") check("Recipe 1: water = 190g", r1["water_g"] == 190, f"got {r1.get('water_g')}g") # Recipe 2: Tallow-based — 400g tallow / 100g coconut, 0% superfat # Tallow SAP ~0.1405, coconut ~0.1815 # Expected: 400*0.1405 + 100*0.1815 = 56.2 + 18.15 = 74.35g NaOH (no superfat discount) recipe2 = {"tallow (beef)": 400, "coconut oil": 100} r2 = calculate_lye(recipe2, lye_type="naoh", superfat=0, water_ratio=0.38) check("Recipe 2 (tallow): NaOH in range (72–77g)", 72 <= r2.get("naoh_g", 0) <= 77, f"got {r2.get('naoh_g')}g") # Recipe 3: Lard-based — 500g lard, 5% superfat # Lard SAP ~0.1375, * 500 * 0.95 = ~65.3g recipe3 = {"lard": 500} r3 = calculate_lye(recipe3, lye_type="naoh", superfat=5) check("Recipe 3 (lard): NaOH in range (62–68g)", 62 <= r3.get("naoh_g", 0) <= 68, f"got {r3.get('naoh_g')}g") # Recipe 4: KOH for liquid soap — 500g olive oil recipe4 = {"olive oil": 500} r4 = calculate_lye(recipe4, lye_type="koh", superfat=0, koh_purity=90) check("Recipe 4 (KOH): KOH > NaOH equivalent", r4.get("koh_g", 0) > 0, f"got {r4.get('koh_g')}g") check("Recipe 4 (KOH): in expected range (100–110g at 90% purity)", 100 <= r4.get("koh_g", 0) <= 110, f"got {r4.get('koh_g')}g") # Recipe 5: Unknown oil — should return error r5 = calculate_lye({"dragon oil": 500}) check("Recipe 5: unknown oil returns error", "error" in r5, r5.get("error", "")) # --------------------------------------------------------------------------- # 3. Soap quality scorer # --------------------------------------------------------------------------- print("\n── Quality scorer ──") # Standard coconut/olive is a known recipe — coconut pushes hardness + cleansing up scores1 = score_recipe({"coconut oil": 300, "olive oil": 200}) check("Scorer: coconut/olive — hardness score returned", "hardness" in scores1, "") check("Scorer: coconut/olive — cleansing > 12 (coconut should dominate)", scores1.get("cleansing", {}).get("value", 0) > 12, f"got {scores1.get('cleansing', {}).get('value')}") # All-olive — known to be low cleansing, high conditioning, very high iodine scores2 = score_recipe({"olive oil": 500}) check("Scorer: all-olive — conditioning high (>70)", scores2.get("conditioning", {}).get("value", 0) > 70, f"got {scores2.get('conditioning', {}).get('value')}") check("Scorer: all-olive — cleansing low (<12, status=low)", scores2.get("cleansing", {}).get("status") == "low", f"got {scores2.get('cleansing', {}).get('status')}") check("Scorer: all-olive — iodine high (>70, status=high)", scores2.get("iodine_value", {}).get("status") == "high", f"got {scores2.get('iodine_value', {}).get('value')}") # --------------------------------------------------------------------------- # 4. Substitution finder # --------------------------------------------------------------------------- print("\n── Substitution finder ──") # Palm oil substitute — should find tallow, lard (similar palmitic/stearic ratio) subs = find_substitutes("palm oil", n=3) check("Substitution: palm oil returns 3 results", len(subs) == 3, "") sub_names = [s["name"].lower() for s in subs] check("Substitution: palm oil → tallow or lard in top 3", any("tallow" in n or "lard" in n for n in sub_names), f"got: {[s['name'] for s in subs]}") # Coconut oil vegan substitute subs_vegan = find_substitutes("coconut oil", n=3, vegan_only=True) check("Substitution: vegan=True excludes non-vegan oils", all(s["vegan"] for s in subs_vegan), f"got: {[s['name'] for s in subs_vegan]}") # Palm kernel should be closest to coconut subs_coco = find_substitutes("coconut oil", n=5) top_names = [s["name"].lower() for s in subs_coco[:2]] check("Substitution: coconut → palm kernel or babassu in top 2", any("palm kernel" in n or "babassu" in n for n in top_names), f"got: {[s['name'] for s in subs_coco[:2]]}") # --------------------------------------------------------------------------- # 5. Cure timeline # --------------------------------------------------------------------------- print("\n── Cure timeline ──") # All-olive — should recommend long cure (8 weeks) cure_olive = cure_timeline({"olive oil": 500}) check("Cure: all-olive recommends 8 weeks", cure_olive.get("recommended_cure_weeks") == 8, f"got {cure_olive.get('recommended_cure_weeks')}") check("Cure: all-olive has 8 weekly notes", len(cure_olive.get("weekly_notes", {})) == 8, "") # Coconut-heavy — should recommend shorter cure (4 weeks) cure_coco = cure_timeline({"coconut oil": 400, "olive oil": 100}) check("Cure: coconut-heavy recommends ≤5 weeks", cure_coco.get("recommended_cure_weeks", 99) <= 5, f"got {cure_coco.get('recommended_cure_weeks')}") # --------------------------------------------------------------------------- # 6. Profile summary # --------------------------------------------------------------------------- print("\n── Profile summary ──") prof = profile_summary({"lard": 300, "coconut oil": 150, "olive oil": 50}) check("Profile: is_vegan=False when lard present", prof.get("is_vegan") == False, "") check("Profile: lard in non_vegan_oils", "Lard" in prof.get("non_vegan_oils", []), "") check("Profile: total_weight correct", prof.get("total_weight_g") == 500, f"got {prof.get('total_weight_g')}") # --------------------------------------------------------------------------- # 7. Helper functions # --------------------------------------------------------------------------- print("\n── Helper functions ──") all_oils = list_oils() check("list_oils: returns >= 40 keys", len(all_oils) >= 40, f"got {len(all_oils)}") vegan_oils = list_oils(vegan_only=True) check("list_oils vegan_only: excludes lard", "lard" not in vegan_oils, "") check("list_oils vegan_only: excludes tallow", "tallow (beef)" not in vegan_oils, "") check("list_oils vegan_only: includes coconut", "coconut oil" in vegan_oils, "") # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- print("\n" + "="*50) passed = sum(1 for s, _, _ in results if s == PASS) failed = sum(1 for s, _, _ in results if s == FAIL) print(f"Results: {passed} passed, {failed} failed out of {len(results)} tests") if failed > 0: print("\nFailed tests:") for s, label, detail in results: if s == FAIL: print(f" {FAIL} {label}" + (f" — {detail}" if detail else "")) sys.exit(1) else: print("All tests passed. Chemistry engine is ready. ✅")