""" G.U.I.D.E. sanity suite — the fast, must-pass gate that runs before every commit. These tests deliberately avoid loading ML checkpoints, the spaCy model, the Anthropic API, or the network. They catch the classes of regression we have actually hit while iterating on the UI/API: * the Gradio app graph failing to build (output/intput-count or scope bugs), * the API request/response schemas breaking, * the three privacy/export features (redaction reveal, audit trail, PDF) silently breaking, * the privacy redactor contract (RedactionResult.spans) changing, * the friendly LLM-error mapping regressing, * and — per SPEC.md — the **How To Guide** tab drifting out of sync whenever a user-facing tab is added or renamed. Run with: python -m pytest tests/test_sanity.py -q This is wired into the git pre-commit hook (see SPEC.md). If any of these fail, the commit is blocked. """ from __future__ import annotations import importlib import re import sys import tempfile from pathlib import Path import pytest _ROOT = Path(__file__).resolve().parent.parent _UI = _ROOT / "ui" for _p in (str(_ROOT), str(_UI)): if _p not in sys.path: sys.path.insert(0, _p) @pytest.fixture(scope="module") def app_module(): """Import ui/app.py once for the whole module.""" return importlib.import_module("app") # --------------------------------------------------------------------------- # 1. The Gradio app graph must build (catches event/component wiring bugs). # --------------------------------------------------------------------------- def test_ui_app_builds(app_module): demo = app_module.build_app() assert demo is not None assert type(demo).__name__ == "Blocks" # --------------------------------------------------------------------------- # 2. API schemas import and validate. # --------------------------------------------------------------------------- def test_api_models_and_routes_import(): from src.api import models, routes # noqa: F401 msg = models.MessageResponse( reply="hi", pii_redacted=True, pii_types_found=["PERSON"], original_text="a", redacted_text="b", redactions=[models.RedactionSpanOut( entity_type="PERSON", original="a", placeholder="", start=0, end=1)], ) assert msg.redactions[0].placeholder == "" audit = models.AuditResponse(session_id="s", entries=[models.AuditEntry( timestamp="t", event="outbound_to_anthropic", description="d", transmitted_text="b", pii_types_found=["PERSON"], pii_count=1, leak_check="passed")]) assert audit.entries[0].leak_check == "passed" # --------------------------------------------------------------------------- # 3. Privacy redactor contract — RedactionResult must carry per-span reveal data. # --------------------------------------------------------------------------- def test_redactor_result_contract(): from src.privacy.redactor import RedactionResult, RedactionSpan r = RedactionResult(redacted_text="x") assert hasattr(r, "spans") and r.spans == [] span = RedactionSpan(entity_type="PERSON", original="Ravi", placeholder="", start=0, end=4) assert span.placeholder == "" # --------------------------------------------------------------------------- # 4. Feature helpers: redaction reveal, audit render, PDF export. # --------------------------------------------------------------------------- def test_redaction_reveal_html(app_module): resp = { "original_text": "I am Ravi, phone 9876543210.", "redacted_text": "I am , phone .", "redactions": [ {"entity_type": "PERSON", "original": "Ravi", "placeholder": "", "start": 5, "end": 9}, {"entity_type": "PHONE_NUMBER", "original": "9876543210", "placeholder": "", "start": 17, "end": 27}, ], } html = app_module._render_redaction_html(resp) assert "Ravi" in html and "<PERSON>" in html assert "2 personal identifier" in html # Nothing stripped → panel hidden. assert app_module._render_redaction_html({"redactions": []}) == "" def test_audit_render(app_module): assert "No activity yet" in app_module._render_audit_html([]) entries = [{ "timestamp": "2026-06-23T12:30:01+00:00", "event": "outbound_to_anthropic", "description": "Message sent to Claude API — 1 personal identifier stripped.", "transmitted_text": "I am ", "pii_types_found": ["PERSON"], "pii_count": 1, "leak_check": "passed", }] out = app_module._render_audit_html(entries) assert "0 raw identifiers transmitted" in out def test_extract_draft_is_letter_only(app_module): """The Complaint Draft box must contain ONLY the letter (Subject: … Contact:), with no preamble and no trailing tips / escalation-invite chatter.""" reply = ( "Here is your complaint letter:\n\n" "---\n" "Subject: Complaint regarding contaminated food product\n\n" "To,\nThe Grievance Officer\nABC Foods Ltd.\n\n" "Dear Sir/Madam,\n\n" "I am writing to complain ...\n\n" "Yours sincerely,\n\nDate: 23rd July 2025\n" "Contact: / \n" "---\n\n" "> 📎 Tip: Attach all medical bills as Annexure A.\n\n" 'Whenever you\'re ready, just ask me to "generate the escalation guide".' ) draft = app_module._extract_draft(reply) assert draft.startswith("Subject:") assert draft.rstrip().endswith("Contact: / ") assert "Here is your complaint letter" not in draft assert "Tip:" not in draft assert "escalation guide" not in draft def test_pdf_export(app_module): draft = ("Subject: Test complaint\n\nTo,\nThe Grievance Officer\n\n" "Dear Sir/Madam,\n\nThis is a test.\n\nYours sincerely,\n") out = tempfile.mktemp(suffix=".pdf") app_module._build_pdf(draft, out) data = Path(out).read_bytes() assert data[:5] == b"%PDF-" and len(data) > 800 # --------------------------------------------------------------------------- # 5. Friendly LLM-error mapping (overloaded / rate-limit / timeout). # --------------------------------------------------------------------------- def test_llm_error_mapping(): from src.api.routes import _llm_http_error assert _llm_http_error(Exception("Overloaded")).status_code == 503 assert _llm_http_error(Exception("rate limit exceeded")).status_code == 429 assert _llm_http_error(Exception("Request timed out")).status_code == 504 overloaded = _llm_http_error(Exception("Overloaded")) assert "overloaded" in overloaded.detail.lower() # --------------------------------------------------------------------------- # 6. SPEC rule: the "How To Guide" tab must mention every user-facing tab. # This fails the moment a tab is added/renamed without updating the guide. # --------------------------------------------------------------------------- def _plain(label: str) -> str: """Strip emoji/punctuation from a tab label → its plain name.""" return re.sub(r"[^A-Za-z ]", "", label).strip() def test_how_to_guide_lists_every_tab(): src = (_UI / "app.py").read_text(encoding="utf-8") labels = re.findall(r'gr\.Tab\("([^"]+)"\)', src) assert labels, "No gr.Tab(...) definitions found — did the UI structure change?" guide_start = src.index('gr.Tab("📖 How To Guide")') guide_end = src.index('gr.Tab("💬 Chat")') guide_text = src[guide_start:guide_end] # Every tab except the guide itself and the technical About tab must be # described in the How To Guide. skip = {"How To Guide", "About"} missing = [ _plain(lbl) for lbl in labels if _plain(lbl) not in skip and _plain(lbl) not in guide_text ] assert not missing, ( f"The 'How To Guide' tab does not mention these tabs: {missing}. " "Per SPEC.md, update the How To Guide whenever you add or rename a tab." )