File size: 10,412 Bytes
3ea2835 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | # Regression tests for the field bug-report fixes of 2026-06-03 (see ADR 0016 and
# ADR 0017). Run with: python scripts/test_bug_report_fixes.py
# Mocks Firebase/Gemini so main.py imports cleanly; only pure logic is exercised.
import os, sys, json
os.environ.setdefault("GEMINI_API_KEY", "fake-key-for-tests")
os.environ.setdefault("FIREBASE", "{}")
os.environ.setdefault("aai_key", "fake")
os.environ.setdefault("whatsapp_token", "fake")
os.environ.setdefault("phone_number_id", "0000")
os.environ.setdefault("WHATSAPP_PROXY_URL", "")
os.environ.setdefault("VERIFY_TOKEN", "fake")
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import firebase_admin
from firebase_admin import credentials, firestore
_fake_db = mock.MagicMock()
with mock.patch.object(credentials, "Certificate", return_value=mock.MagicMock()), \
mock.patch.object(firebase_admin, "initialize_app", return_value=mock.MagicMock()), \
mock.patch.object(firestore, "client", return_value=_fake_db):
import main
import utility
PASS = FAIL = 0
def check(desc, got, want):
global PASS, FAIL
ok = got == want
PASS += ok; FAIL += (not ok)
print(("PASS" if ok else "FAIL") + f" {desc}\n got={got!r} want={want!r}" if not ok else f"PASS {desc}")
def check_true(desc, got): check(desc, bool(got), True)
def check_false(desc, got): check(desc, bool(got), False)
print("== Bug 2: price-format equivalence (_parse_price_reply) ==")
check("carrot: USD0.15", main._parse_price_reply("carrot: USD0.15"), {"carrot": 0.15})
check("carrot: 0.15 dollars", main._parse_price_reply("carrot: 0.15 dollars"), {"carrot": 0.15})
check("carrot: 0.15 USD", main._parse_price_reply("carrot: 0.15 USD"), {"carrot": 0.15})
check("carrot: 15 cents", main._parse_price_reply("Carrot: 15 cents"), {"carrot": 0.15})
check("carrot: 50c", main._parse_price_reply("carrot: 50c"), {"carrot": 0.5})
check("peanut butter - $3", main._parse_price_reply("peanut butter - $3"), {"peanut butter": 3.0})
check("multiline", main._parse_price_reply("jam: USD2\nbread 1.20 each"), {"jam": 2.0, "bread": 1.2})
check("skip", main._parse_price_reply("skip"), {})
check("CRUD not price", main._parse_price_reply("update transaction abc123 to USD12"), None)
check("question not price", main._parse_price_reply("what is on sale?"), None)
print("== Bug 2: bare money reply ==")
check("USD0.50", main._parse_bare_money_reply("USD0.50"), 0.5)
check("0.50 dollars", main._parse_bare_money_reply("0.50 dollars"), 0.5)
check("50c", main._parse_bare_money_reply("50c"), 0.5)
check("0.50 each", main._parse_bare_money_reply("0.50 each"), 0.5)
check("plain 2", main._parse_bare_money_reply("2"), 2.0)
check("not money", main._parse_bare_money_reply("2 jam"), None)
check("not money txt", main._parse_bare_money_reply("hello"), None)
print("== Bug 1: move-item extraction ==")
check("move dovi", main._extract_move_item("move dovi"), "dovi")
check("put dovi on sale", main._extract_move_item("put dovi on sale"), "dovi")
check("discount tomatoes", main._extract_move_item("discount tomatoes"), "tomatoes")
check("question guarded", main._extract_move_item("what is on sale?"), None)
print("== Bugs 4+5: debt query detection ==")
check_true(" who do I owe money?", utility.is_debt_query("Who do I owe money?"))
check_true(" who do I owe?", utility.is_debt_query("Who do I owe?"))
check_true(" who owes me money?", utility.is_debt_query("Who owes me money?"))
check_true(" show my payables", utility.is_debt_query("show my payables"))
check_true(" my debtors", utility.is_debt_query("list my debtors"))
check_false(" I owe John 50 (record, not ask)", utility.is_debt_query("I owe John 50"))
check_false(" Chipo owes me 20 for bread", utility.is_debt_query("Chipo owes me 20 for bread"))
check_false(" sold 2 jam", utility.is_debt_query("sold 2 jam"))
print("== Bugs 4+5: canonical report format ==")
custs = [{"name": "Supplier A", "payable": 50.0, "receivable": 0},
{"name": "Supplier B", "payable": 75.0, "receivable": 0},
{"name": "Chipo", "payable": 0, "receivable": 20.0}]
rep = utility._handle_customer_credit_query("who do i owe money?", custs, "USD")
print(rep); print("---")
check_true(" has You owe heading", rep.startswith("*You owe:*"))
check_true(" has em-dash lines", "Supplier A — USD50.00" in rep)
check_true(" has total", "*Total you owe:* USD125.00" in rep)
rep2 = utility._handle_customer_credit_query("who owes me money?", custs, "USD")
check_true(" receivables heading", rep2.startswith("*Who owes you:*"))
check_true(" receivable line", "Chipo — USD20.00" in rep2)
check_true(" receivable total", "*Total owed to you:* USD20.00" in rep2)
rep3 = utility._handle_customer_credit_query("who owes me and who do i owe", custs, "USD")
check_true(" combined both", ("*Who owes you:*" in rep3) and ("*You owe:*" in rep3))
print("== Bug 3: money-mention provenance ==")
check_false(" sold 40 tomatoes to Chipo", utility._has_money_mention("I sold 40 tomatoes to Chipo"))
check_true(" sold 2 jam for 4", utility._has_money_mention("sold 2 jam for 4"))
check_true(" sold 2 jam at 3 each", utility._has_money_mention("sold 2 jam at 3 each"))
check_true(" USD4", utility._has_money_mention("sold jam USD4"))
check_true(" 40 dollars", utility._has_money_mention("sold tomatoes 40 dollars"))
check_true(" paid 20", utility._has_money_mention("sold jam, she paid 20"))
print("== Bug 3: promo price wins over guessed amount (resolve_sale_prices) ==")
class _Doc:
def __init__(self, d): self._d = d
def to_dict(self): return self._d
class _Q:
def __init__(self, docs): self._docs = docs
def where(self, *a, **k): return self
def get(self): return self._docs
class _Coll:
def __init__(self, name_map): self.name_map = name_map
def document(self, _): return self
def collection(self, name): return _Q(self.name_map.get(name, []))
promo_db = mock.MagicMock()
promo_db.collection.return_value = _Coll({
"stock_batches": [_Doc({"name": "tomato", "on_sale": True, "sale_price_each": 0.51,
"quantity_remaining": 200, "price_each": 0.51})],
"price_overrides": [],
})
txns = [{"intent": "create", "transaction_type": "sale",
"details": {"items": [{"item": "tomato", "quantity": 40, "unit": "each"}],
"amount": 40.0, "customer": "Chipo"}}]
utility.resolve_sale_prices(promo_db, "263771", txns, raw_text="I sold 40 tomatoes to Chipo")
d = txns[0]["details"]; it = d["items"][0]
check(" promo ppu", it.get("price_per_unit"), 0.51)
check(" promo total", d.get("amount"), 20.4)
check_true(" promo flag survives _infer_prices", it.get("promo_applied"))
# Explicit user price must NOT be overridden by the promo.
txns2 = [{"intent": "create", "transaction_type": "sale",
"details": {"items": [{"item": "tomato", "quantity": 40, "unit": "each",
"price_per_unit": 1.0}], "amount": 40.0}}]
utility.resolve_sale_prices(promo_db, "263771", txns2, raw_text="I sold 40 tomatoes for $1 each")
check(" explicit price kept", txns2[0]["details"]["items"][0].get("price_per_unit"), 1.0)
check_true(" explicit flag set", txns2[0]["details"].get("explicit_price"))
print("== Bug 6: stock-in cost prompt + remembered-cost correction ==")
stock_txn = [{"intent": "create", "transaction_type": "stock_in",
"details": {"items": [{"item": "jam", "quantity": 100, "unit": "each"}]}}]
prompt = main._check_missing_details(stock_txn, currency="USD")
print(prompt); print("---")
check_true(" asks cost for jam", prompt is not None and "pay for the Jam" in prompt)
stock_ok = [{"intent": "create", "transaction_type": "stock_in",
"details": {"items": [{"item": "orange", "quantity": 100, "unit": "each",
"price_per_unit": 0.15}]}}]
check(" no prompt when cost known", main._check_missing_details(stock_ok, currency="USD"), None)
remembered = [{"intent": "create", "transaction_type": "stock_in",
"details": {"items": [{"item": "orange", "quantity": 100, "unit": "each",
"price_per_unit": 0.15, "cost_source": "remembered"}],
"amount": 15.0}}]
res = main._try_fill_missing_details("0.20 each", remembered, currency="USD", mobile="")
check_true(" correction accepted", res is not None)
if res:
filled, still = res
it = filled[0]["details"]["items"][0]
check(" corrected ppu", it.get("price_per_unit"), 0.2)
check(" corrected total", filled[0]["details"].get("amount"), 20.0)
check(" nothing still missing", still, None)
print("== card shows remembered-cost note ==")
card = utility.format_transaction_response([{
"intent": "create", "transaction_type": "stock_in",
"details": {"items": [{"item": "orange", "quantity": 100, "unit": "each",
"price_per_unit": 0.15, "cost_source": "remembered"}],
"currency": "USD"}}])
print(card); print("---")
check_true(" last-cost marker", "your last cost" in card)
check_true(" changed-cost hint", "reply with the new cost" in card)
card2 = utility.format_transaction_response([{
"intent": "create", "transaction_type": "sale",
"details": {"items": [{"item": "tomato", "quantity": 40, "unit": "each",
"price_per_unit": 0.51, "promo_applied": True}],
"currency": "USD"}}])
print(card2); print("---")
check_true(" on-sale marker", "on sale" in card2)
check_false(" no raw flag leak", "Promo Applied" in card2 or "Explicit Price" in card2)
print("== _money_to_float equivalence ==")
for v, want in [("USD0.15", 0.15), ("0.15 USD", 0.15), ("0.15 dollars", 0.15),
("15 cents", 0.15), ("50c", 0.5), ("R50", 50.0), ("4", 4.0)]:
check(f" {v}", utility._money_to_float(v), want)
print("== misc: normalise, debt-regex safety ==")
check(" carrots→carrot", utility.normalise_item_name("Carrots"), "carrot")
check(" tomatoes→tomato", utility.normalise_item_name("tomatoes"), "tomato")
print(f"\n{'='*40}\nTOTAL: {PASS} passed, {FAIL} failed")
sys.exit(1 if FAIL else 0)
|