guide / tests /test_sanity.py
anmol-iisc's picture
UI enhancements, letter text redundant text removed
d230384
Raw
History Blame Contribute Delete
8.17 kB
"""
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="<PERSON>",
start=0, end=1)],
)
assert msg.redactions[0].placeholder == "<PERSON>"
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="<PERSON>", start=0, end=4)
assert span.placeholder == "<PERSON>"
# ---------------------------------------------------------------------------
# 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 <PERSON>, phone <PHONE_NUMBER>.",
"redactions": [
{"entity_type": "PERSON", "original": "Ravi",
"placeholder": "<PERSON>", "start": 5, "end": 9},
{"entity_type": "PHONE_NUMBER", "original": "9876543210",
"placeholder": "<PHONE_NUMBER>", "start": 17, "end": 27},
],
}
html = app_module._render_redaction_html(resp)
assert "Ravi" in html and "&lt;PERSON&gt;" 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 <PERSON>", "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<PERSON>\nDate: 23rd July 2025\n"
"Contact: <EMAIL_ADDRESS> / <PHONE_NUMBER>\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: <EMAIL_ADDRESS> / <PHONE_NUMBER>")
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<PERSON>")
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."
)