This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
"""
test_milestone4.py — Automated success tests for Milestone 4
Milestone 4 success checklist (from new-milestone.md):
✅ run.py is a runnable entry point with run_bots()
✅ voice/elevenlabs_tts generates MP3 with cross-platform path
✅ build_voice_text returns text for all 8 feature types
✅ UPI link generated for expense-type negotiations
✅ on_resolution in bot.py wires voice + UPI + analytics
✅ analytics stored in DB after negotiation
✅ run.py imports cleanly (all modules resolvable)
✅ bot.py imports cleanly (voice + UPI imports added)
✅ fairness score computed correctly
✅ No crashes when ElevenLabs key is missing (fallback to None)
Run from project root:
cd e:\\negot8
python test/test_milestone4.py
"""
import sys, os, asyncio, json, importlib
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BACKEND = os.path.join(ROOT, "backend")
BOTS = os.path.join(BACKEND, "telegram-bots")
sys.path.insert(0, BACKEND)
sys.path.insert(0, BOTS)
PASS = ""
FAIL = ""
results = []
def record(ok: bool, name: str, detail: str = ""):
results.append((PASS if ok else FAIL, name, detail))
print(f" {PASS if ok else FAIL} {name}" + (f" [{detail}]" if detail else ""))
# ═══════════════════════════════════════════════════════════════
# 1. Module import checks
# ═══════════════════════════════════════════════════════════════
def test_imports():
print("\n── 1. Module imports ──")
for mod, label in [
("run", "run.py imports cleanly"),
("bot", "bot.py imports cleanly"),
("voice.elevenlabs_tts", "elevenlabs_tts imports cleanly"),
]:
try:
importlib.import_module(mod)
record(True, label)
except Exception as e:
record(False, label, str(e)[:120])
# Verify new symbols exist in bot.py
try:
import bot
has_voice = hasattr(bot, "generate_voice_summary") or \
"generate_voice_summary" in getattr(bot, "__dict__", {}) or \
True # imported into local scope via 'from ... import'
from bot import _upi_tool, VOICE_ID_AGENT_A, VOICE_ID_AGENT_B
record(True, "bot.py has _upi_tool, VOICE_ID constants")
except ImportError as e:
record(False, "bot.py has _upi_tool, VOICE_ID constants", str(e))
# Verify run.py has run_bots
try:
from run import run_bots
record(True, "run.py exposes run_bots()")
except ImportError as e:
record(False, "run.py exposes run_bots()", str(e))
# ═══════════════════════════════════════════════════════════════
# 2. run.py entry point structure
# ═══════════════════════════════════════════════════════════════
def test_run_py_structure():
print("\n── 2. run.py structure ──")
import inspect, run
# run_bots is a coroutine function
record(asyncio.iscoroutinefunction(run.run_bots),
"run_bots() is an async function")
# Source contains if __name__ == "__main__"
try:
src = open(os.path.join(BACKEND, "run.py")).read()
record('if __name__ == "__main__"' in src,
'run.py has if __name__ == "__main__" block')
record("asyncio.run(run_bots())" in src,
"run.py calls asyncio.run(run_bots())")
except Exception as e:
record(False, "run.py source check", str(e))
# ═══════════════════════════════════════════════════════════════
# 3. ElevenLabs TTS — cross-platform path + templates
# ═══════════════════════════════════════════════════════════════
def test_voice_module():
print("\n── 3. Voice module ──")
from voice.elevenlabs_tts import build_voice_text, VOICE_TEMPLATES
# All 8 feature types have templates
expected = {"expenses", "collaborative", "scheduling", "marketplace",
"trip", "freelance", "roommate", "conflict"}
actual = set(VOICE_TEMPLATES.keys())
record(expected.issubset(actual),
"All 8 feature types have voice templates",
f"missing: {expected - actual}" if not expected.issubset(actual) else "all present")
# build_voice_text returns safe string when keys missing
text = build_voice_text("scheduling", {"rounds": 3, "date": "Monday", "time": "10am", "location": "Blue Tokai"})
record(isinstance(text, str) and len(text) > 5,
"build_voice_text returns non-empty string for scheduling",
text[:60])
text2 = build_voice_text("unknown_feature", {"rounds": 2, "summary": "done"})
record(isinstance(text2, str) and "negotiation" in text2.lower(),
"build_voice_text has safe fallback for unknown feature types",
text2[:60])
# Check no hardcoded /tmp/ path in tts module
src = open(os.path.join(BACKEND, "voice", "elevenlabs_tts.py")).read()
record("/tmp/" not in src,
"elevenlabs_tts.py uses cross-platform path (no hardcoded /tmp/)")
record("tempfile.gettempdir()" in src,
"elevenlabs_tts.py uses tempfile.gettempdir()")
# ═══════════════════════════════════════════════════════════════
# 4. ElevenLabs TTS — graceful failure without API key
# ═══════════════════════════════════════════════════════════════
async def test_voice_graceful_failure():
print("\n── 4. Voice graceful failure (no real API call) ──")
import unittest.mock as mock
from voice.elevenlabs_tts import generate_voice_summary
# Patch httpx to simulate API error (401)
class FakeResp:
status_code = 401
text = "Unauthorized"
class FakeClient:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def post(self, *a, **kw): return FakeResp()
with mock.patch("httpx.AsyncClient", return_value=FakeClient()):
result = await generate_voice_summary("test text", "neg_test_001")
record(result is None,
"generate_voice_summary returns None on API failure (no crash)")
# ═══════════════════════════════════════════════════════════════
# 5. UPI tool works
# ═══════════════════════════════════════════════════════════════
async def test_upi_tool():
print("\n── 5. UPI tool ──")
from tools.upi_generator import UPIGeneratorTool
tool = UPIGeneratorTool()
result = await tool.execute(
payee_upi="alice@upi",
payee_name="Alice",
amount=500.0,
note="negoT8: expenses settlement"
)
record("upi_link" in result and result["upi_link"].startswith("upi://"),
"UPI link generated with upi:// scheme",
result.get("upi_link", "")[:60])
record("alice@upi" in result["upi_link"],
"UPI link contains payee UPI ID")
# ═══════════════════════════════════════════════════════════════
# 6. Analytics DB storage
# ═══════════════════════════════════════════════════════════════
async def test_analytics_storage():
print("\n── 6. Analytics DB storage ──")
import database as db
await db.init_db()
NEG_ID = "m4test001"
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute(
"INSERT OR REPLACE INTO negotiations (id, feature_type, initiator_id) VALUES (?, ?, ?)",
(NEG_ID, "expenses", 55555)
)
await conn.commit()
timeline = [{"round": 1, "score_a": 80, "score_b": 60},
{"round": 2, "score_a": 85, "score_b": 75}]
fairness = 100 - abs(85 - 75) # = 90
await db.store_analytics({
"negotiation_id": NEG_ID,
"satisfaction_timeline": json.dumps(timeline),
"concession_log": json.dumps([{"round": 1, "by": "A", "gave_up": "morning slot"}]),
"fairness_score": fairness,
"total_concessions_a": 1,
"total_concessions_b": 0,
})
row = await db.get_analytics(NEG_ID)
record(row is not None, "Analytics row stored in negotiation_analytics table")
if row:
row = dict(row)
record(abs(row["fairness_score"] - 90) < 0.01,
f"Fairness score stored correctly",
str(row["fairness_score"]))
record(row["total_concessions_a"] == 1,
"total_concessions_a stored correctly",
str(row["total_concessions_a"]))
# Cleanup
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute("DELETE FROM negotiation_analytics WHERE negotiation_id = ?", (NEG_ID,))
await conn.execute("DELETE FROM negotiations WHERE id = ?", (NEG_ID,))
await conn.commit()
# ═══════════════════════════════════════════════════════════════
# 7. bot.py on_resolution wiring — check source for voice+analytics
# ═══════════════════════════════════════════════════════════════
def test_resolution_wiring():
print("\n── 7. on_resolution wiring in bot.py ──")
src = open(os.path.join(BOTS, "bot.py")).read()
record("generate_voice_summary" in src,
"bot.py calls generate_voice_summary in on_resolution")
record("build_voice_text" in src,
"bot.py calls build_voice_text in on_resolution")
record("store_analytics" in src,
"bot.py calls db.store_analytics in on_resolution")
record("upi_link" in src,
"bot.py generates UPI link in on_resolution")
record("send_voice" in src,
"bot.py sends voice note via send_voice in on_resolution")
# ═══════════════════════════════════════════════════════════════
# 8. Decline-before-get bug fixed
# ═══════════════════════════════════════════════════════════════
def test_decline_bug_fixed():
print("\n── 8. Decline-before-get bug fix ──")
src = open(os.path.join(BOTS, "bot.py")).read()
# After the fix, data = get(...) should come BEFORE pop(...)
# within the decline branch
decline_block_start = src.find('if action == "decline":')
if decline_block_start == -1:
record(False, "decline branch found in bot.py")
return
record(True, "decline branch found in bot.py")
# Get the slice of text for the decline handler (up to return)
decline_slice = src[decline_block_start:decline_block_start + 600]
pos_get = decline_slice.find("pending_coordinations.get(neg_id")
pos_pop = decline_slice.find("pending_coordinations.pop(neg_id")
record(pos_get < pos_pop and pos_get != -1 and pos_pop != -1,
"initiator_id is fetched BEFORE pop() in decline branch",
f"get@{pos_get} pop@{pos_pop}")
# ═══════════════════════════════════════════════════════════════
# Main runner
# ═══════════════════════════════════════════════════════════════
async def run_all():
test_imports()
test_run_py_structure()
test_voice_module()
await test_voice_graceful_failure()
await test_upi_tool()
await test_analytics_storage()
test_resolution_wiring()
test_decline_bug_fixed()
passed = sum(1 for s, _, _ in results if s == PASS)
total = len(results)
print("\n" + "=" * 60)
print(f" MILESTONE 4 TESTS: {passed}/{total} passed")
print("=" * 60)
if passed == total:
print("""
🏆 ALL TESTS PASSED — Milestone 4 is ready!
══ HOW TO RUN (Live Telegram test) ══════════════════════════
cd e:\\negot8\\backend
python run.py
══ FULL FLOW TEST (two phones) ══════════════════════════════
Phone A: /start
/personality → pick Aggressive (😤)
/coordinate @PhoneB_username
"Split our hotel bill, I paid ₹8000, john@upi, want 60-40 split"
Phone B: /start
/personality → pick Empathetic (💚)
/pending → tap Accept
"I think 50-50 is fair, my upi is jane@upi"
▶ Watch round-by-round updates appear on BOTH phones
▶ Final resolution arrives with 📊 satisfaction scores
▶ 🎙 Voice note sent to each user (different voices!)
▶ 💳 UPI Pay button appended to expense resolutions
▶ Check DB: sqlite3 negot8.db "SELECT * FROM negotiation_analytics;"
""")
else:
failed = [(n, d) for s, n, d in results if s == FAIL]
print(f"\n⚠️ {total - passed} test(s) failed:")
for name, detail in failed:
print(f"{name}")
if detail:
print(f" {detail}")
print()
if __name__ == "__main__":
asyncio.run(run_all())