From 930e86cb83de557ddafb2d08e32f584d0e6e4bad Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Thu, 16 Apr 2026 01:52:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20edge=20crisis=20detection=20?= =?UTF-8?q?=E2=80=94=20tests/test=5Fedge=5Fcrisis.py=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_edge_crisis.py | 209 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/test_edge_crisis.py diff --git a/tests/test_edge_crisis.py b/tests/test_edge_crisis.py new file mode 100644 index 00000000..d3547e11 --- /dev/null +++ b/tests/test_edge_crisis.py @@ -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)