soap-lab / test_chemistry.py
293droid
Initial commit - fixed README YAML configuration
b4470d5
Raw
History Blame
8.89 kB
"""
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. βœ…")