""" 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())