feat: add edge crisis detection — tests/test_edge_crisis.py (#102)
This commit is contained in:
209
tests/test_edge_crisis.py
Normal file
209
tests/test_edge_crisis.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user