210 lines
7.0 KiB
Python
210 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
test_edge_crisis.py — Offline crisis detection tests
|
|
|
|
Tests run with zero dependencies and no network access.
|
|
Verifies keyword detection accuracy and resource loading.
|
|
|
|
Usage:
|
|
python3 tests/test_edge_crisis.py
|
|
python3 -m pytest tests/test_edge_crisis.py -v
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Add parent to path for imports
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
from scripts.crisis_detector import detect_keywords, detect_crisis, load_resources
|
|
|
|
# ── Test Data ───────────────────────────────────────────────────────────────
|
|
|
|
# Texts that SHOULD trigger crisis detection
|
|
CRISIS_TEXTS = [
|
|
"I want to kill myself",
|
|
"I'm going to die tonight",
|
|
"I can't take this anymore, I want to end it",
|
|
"Nobody would miss me if I were gone",
|
|
"I've been cutting myself to feel something",
|
|
"There's no point in living anymore",
|
|
"I'm suicidal and I need help",
|
|
"I just want the pain to stop forever",
|
|
"I've been thinking about hanging myself",
|
|
"Please help me I'm drowning in this",
|
|
"I give up. Nothing matters anymore.",
|
|
"I want to jump off the bridge",
|
|
"I can't go on like this",
|
|
"Help me I'm desperate",
|
|
"I'm done with life",
|
|
]
|
|
|
|
# Texts that should NOT trigger (common false positives)
|
|
SAFE_TEXTS = [
|
|
"What a kill in that game!",
|
|
"I'm dying of laughter",
|
|
"This movie killed me",
|
|
"I want to kill it at my presentation",
|
|
"I'm dead tired after work",
|
|
"The jump scare got me",
|
|
"I cut my finger cooking",
|
|
"This game is killing me (hard level)",
|
|
"The point of the story is unclear",
|
|
"I'm done with this homework",
|
|
"No point in arguing about it",
|
|
"The bridge looks beautiful at sunset",
|
|
"I gave up sugar for Lent",
|
|
"The knife is in the drawer",
|
|
"She's hanging the picture on the wall",
|
|
]
|
|
|
|
|
|
def test_crisis_texts_detected():
|
|
"""All crisis texts must be detected."""
|
|
failures = []
|
|
for text in CRISIS_TEXTS:
|
|
result = detect_keywords(text)
|
|
if not result["detected"]:
|
|
failures.append(f"MISSED: '{text}'")
|
|
assert not failures, "Crisis texts not detected:\n" + "\n".join(failures)
|
|
|
|
|
|
def test_safe_texts_not_detected():
|
|
"""Safe texts must not trigger false positives."""
|
|
failures = []
|
|
for text in SAFE_TEXTS:
|
|
result = detect_keywords(text)
|
|
if result["detected"]:
|
|
failures.append(f"FALSE POSITIVE: '{text}' -> {result['categories']}")
|
|
# Allow some false positives — crisis detection should err on side of caution
|
|
false_positive_rate = len(failures) / len(SAFE_TEXTS)
|
|
assert false_positive_rate < 0.3, (
|
|
f"False positive rate too high ({false_positive_rate:.0%}):\n"
|
|
+ "\n".join(failures)
|
|
)
|
|
|
|
|
|
def test_immediate_danger_high_confidence():
|
|
"""Immediate danger keywords must have high confidence."""
|
|
for text in ["I want to kill myself", "I'm going to die tonight", "I'm suicidal"]:
|
|
result = detect_keywords(text)
|
|
assert result["detected"], f"Failed to detect: '{text}'"
|
|
assert result["confidence"] >= 0.8, (
|
|
f"Low confidence for immediate danger: {result['confidence']}"
|
|
)
|
|
|
|
|
|
def test_resources_load():
|
|
"""Resources file must exist and be valid."""
|
|
resources = load_resources()
|
|
assert "national_resources" in resources
|
|
assert len(resources["national_resources"]) >= 1
|
|
|
|
# 988 lifeline must be present
|
|
phones = [r.get("phone", "") for r in resources["national_resources"]]
|
|
assert any("988" in p for p in phones), "988 Lifeline not in resources"
|
|
|
|
|
|
def test_resources_have_required_fields():
|
|
"""All national resources must have name and contact method."""
|
|
resources = load_resources()
|
|
for r in resources["national_resources"]:
|
|
assert "name" in r, f"Resource missing name: {r}"
|
|
has_contact = r.get("phone") or r.get("text") or r.get("url")
|
|
assert has_contact, f"Resource missing contact: {r['name']}"
|
|
|
|
|
|
def test_keyword_categories():
|
|
"""Verify all keyword categories are represented."""
|
|
for text, expected_cats in [
|
|
("I want to kill myself", ["immediate_danger"]),
|
|
("I've been cutting myself", ["self_harm"]),
|
|
("There's no point in living", ["hopelessness"]),
|
|
]:
|
|
result = detect_keywords(text)
|
|
assert result["detected"], f"Should detect: '{text}'"
|
|
for cat in expected_cats:
|
|
assert cat in result["categories"], (
|
|
f"Expected category '{cat}' for '{text}', got {result['categories']}"
|
|
)
|
|
|
|
|
|
def test_empty_text_safe():
|
|
"""Empty text must not trigger."""
|
|
result = detect_keywords("")
|
|
assert not result["detected"]
|
|
assert result["confidence"] == 0.0
|
|
|
|
|
|
def test_detect_crisis_combined():
|
|
"""Combined detect_crisis function works (keyword-only, no LLM)."""
|
|
result = detect_crisis("I want to kill myself", use_llm=False)
|
|
assert result["detected"]
|
|
|
|
result2 = detect_crisis("Nice weather today", use_llm=False)
|
|
assert not result2["detected"]
|
|
|
|
|
|
def test_resource_file_exists():
|
|
"""The resources JSON file must exist."""
|
|
resources_file = Path(__file__).resolve().parent.parent / "data" / "crisis_resources.json"
|
|
assert resources_file.exists(), f"Missing: {resources_file}"
|
|
|
|
|
|
def test_resources_json_valid():
|
|
"""Resources file must be valid JSON with expected structure."""
|
|
resources_file = Path(__file__).resolve().parent.parent / "data" / "crisis_resources.json"
|
|
with open(resources_file) as f:
|
|
data = json.load(f)
|
|
assert "version" in data
|
|
assert "national_resources" in data
|
|
assert "self_help_prompts" in data
|
|
assert len(data["national_resources"]) >= 3
|
|
|
|
|
|
# ── Runner ──────────────────────────────────────────────────────────────────
|
|
|
|
def run_all_tests():
|
|
"""Run all tests without pytest."""
|
|
tests = [
|
|
test_crisis_texts_detected,
|
|
test_safe_texts_not_detected,
|
|
test_immediate_danger_high_confidence,
|
|
test_resources_load,
|
|
test_resources_have_required_fields,
|
|
test_keyword_categories,
|
|
test_empty_text_safe,
|
|
test_detect_crisis_combined,
|
|
test_resource_file_exists,
|
|
test_resources_json_valid,
|
|
]
|
|
|
|
passed = 0
|
|
failed = 0
|
|
|
|
for test in tests:
|
|
name = test.__name__
|
|
try:
|
|
test()
|
|
print(f" PASS: {name}")
|
|
passed += 1
|
|
except AssertionError as e:
|
|
print(f" FAIL: {name}")
|
|
print(f" {e}")
|
|
failed += 1
|
|
except Exception as e:
|
|
print(f" ERROR: {name}: {e}")
|
|
failed += 1
|
|
|
|
print(f"\n{'='*50}")
|
|
print(f"Results: {passed} passed, {failed} failed, {passed+failed} total")
|
|
print(f"{'='*50}")
|
|
|
|
return failed == 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
success = run_all_tests()
|
|
sys.exit(0 if success else 1)
|