import pytest from datetime import datetime, timedelta from app.services.customer_health import CustomerHealthService class TestSilenceScore: def test_no_contact_returns_max_days(self): assert CustomerHealthService.silence_days(None) == 999 def test_recent_contact_returns_0_days(self): now = datetime.utcnow() assert CustomerHealthService.silence_days(now) == 0 def test_3_days_ago(self): dt = datetime.utcnow() - timedelta(days=3) assert CustomerHealthService.silence_days(dt) == 3 def test_silence_score_0_days(self): assert CustomerHealthService.calculate_silence_score(datetime.utcnow()) == 100 def test_silence_score_7_days(self): dt = datetime.utcnow() - timedelta(days=7) score = CustomerHealthService.calculate_silence_score(dt) assert score == 50 def test_silence_score_14_days(self): dt = datetime.utcnow() - timedelta(days=14) score = CustomerHealthService.calculate_silence_score(dt) assert score == 0 def test_silence_score_21_days_clamped(self): dt = datetime.utcnow() - timedelta(days=21) score = CustomerHealthService.calculate_silence_score(dt) assert score == 0 class TestStatusWeight: def test_customer_status(self): assert CustomerHealthService.status_weight("customer") == 100 def test_negotiating_status(self): assert CustomerHealthService.status_weight("negotiating") == 70 def test_lead_status(self): assert CustomerHealthService.status_weight("lead") == 40 def test_lost_status(self): assert CustomerHealthService.status_weight("lost") == 10 def test_unknown_status_defaults(self): assert CustomerHealthService.status_weight("unknown") == 40 def test_none_status_defaults(self): assert CustomerHealthService.status_weight(None) == 40 class TestGrade: def test_active_grade(self): assert CustomerHealthService.grade(100) == "active" assert CustomerHealthService.grade(80) == "active" assert CustomerHealthService.grade(85) == "active" def test_watch_grade(self): assert CustomerHealthService.grade(79) == "watch" assert CustomerHealthService.grade(50) == "watch" assert CustomerHealthService.grade(65) == "watch" def test_critical_grade(self): assert CustomerHealthService.grade(49) == "critical" assert CustomerHealthService.grade(0) == "critical" assert CustomerHealthService.grade(30) == "critical" class TestResponseScore: def test_both_none(self): r = CustomerHealthService.calc_response_score(None, None) assert r["score"] == 50 assert r["trend"] == "stable" def test_only_recent_exists(self): r = CustomerHealthService.calc_response_score(2.0, None) assert r["score"] == 90 assert r["trend"] == "stable" def test_improving_faster_response(self): r = CustomerHealthService.calc_response_score(2.0, 10.0) assert r["score"] == 90 assert r["trend"] == "improving" def test_declining_slower_response(self): r = CustomerHealthService.calc_response_score(10.0, 2.0) assert r["trend"] == "declining" def test_fast_response_high_score(self): r = CustomerHealthService.calc_response_score(0.5, 5.0) assert r["score"] >= 95 assert r["trend"] == "improving" def test_very_slow_response_low_score(self): r = CustomerHealthService.calc_response_score(48.0, 2.0) assert r["score"] == 0 assert r["trend"] == "declining" class TestSentimentScore: def test_empty_messages_neutral(self): r = CustomerHealthService.calc_sentiment_score([]) assert r["score"] == 50 assert r["label"] == "neutral" def test_positive_message(self): r = CustomerHealthService.calc_sentiment_score(["yes I'm interested thanks"]) assert r["score"] == 80 assert r["label"] == "positive" def test_negative_message(self): r = CustomerHealthService.calc_sentiment_score(["no not interested too expensive"]) assert r["score"] == 20 assert r["label"] == "negative" def test_mixed_messages_neutral(self): r = CustomerHealthService.calc_sentiment_score(["good quality", "but price is high"]) assert r["score"] == 50 assert r["label"] == "neutral" def test_more_positive_than_negative(self): r = CustomerHealthService.calc_sentiment_score([ "great product", "yes please proceed", "but shipping is expensive", ]) assert r["score"] == 80 assert r["label"] == "positive" class TestInquiryDepthScore: def test_empty_messages(self): r = CustomerHealthService.calc_inquiry_depth_score([]) assert r["score"] == 0 assert r["signal_count"] == 0 def test_no_signals(self): r = CustomerHealthService.calc_inquiry_depth_score(["hello", "how are you"]) assert r["score"] == 0 def test_one_signal(self): r = CustomerHealthService.calc_inquiry_depth_score(["what is your price"]) assert r["score"] == 50 assert r["signal_count"] >= 1 def test_multiple_signals(self): r = CustomerHealthService.calc_inquiry_depth_score([ "what is your MOQ and FOB price", "do you have certification", "what is the lead time", ]) assert r["score"] >= 75 assert r["signal_count"] >= 3 def test_deduplicates_signals(self): r = CustomerHealthService.calc_inquiry_depth_score([ "what is the price", "please send price and MOQ", ]) assert r["signal_count"] == 2 class TestBusinessValueScore: def test_zero_value(self): r = CustomerHealthService.calc_business_value_score(0) assert r["score"] == 0 def test_small_value(self): r = CustomerHealthService.calc_business_value_score(500) assert r["score"] == 20 def test_medium_value(self): r = CustomerHealthService.calc_business_value_score(5000) assert r["score"] == 40 def test_large_value(self): r = CustomerHealthService.calc_business_value_score(50000) assert r["score"] == 80 def test_very_large_value(self): r = CustomerHealthService.calc_business_value_score(200000) assert r["score"] == 100 class TestTotalScore: def test_perfect_health(self): dims = { "response_trend": {"score": 100}, "sentiment": {"score": 100}, "inquiry_depth": {"score": 100}, "silence": {"score": 100}, "business_value": {"score": 100}, } r = CustomerHealthService.calc_total_score(dims) assert r["total_score"] == 100 assert r["grade"] == "active" def test_zero_health(self): dims = { "response_trend": {"score": 0}, "sentiment": {"score": 0}, "inquiry_depth": {"score": 0}, "silence": {"score": 0}, "business_value": {"score": 0}, } r = CustomerHealthService.calc_total_score(dims) assert r["total_score"] == 0 assert r["grade"] == "critical" def test_mid_health(self): dims = { "response_trend": {"score": 60}, "sentiment": {"score": 50}, "inquiry_depth": {"score": 50}, "silence": {"score": 40}, "business_value": {"score": 50}, } r = CustomerHealthService.calc_total_score(dims) assert 45 <= r["total_score"] <= 55 class TestSuggestion: def test_active_suggestion(self): s = CustomerHealthService.suggestion("active", 1, "lead") assert "良好" in s def test_watch_with_silence(self): s = CustomerHealthService.suggestion("watch", 5, "lead") assert "5天" in s assert "跟进" in s def test_watch_no_silence(self): s = CustomerHealthService.suggestion("watch", 1, "lead") assert "关注" in s def test_critical_lead(self): s = CustomerHealthService.suggestion("critical", 10, "lead") assert "10天" in s assert "跟进" in s def test_critical_lost(self): s = CustomerHealthService.suggestion("critical", 20, "lost") assert "重新激活" in s class TestHealthOverview: def test_overview_empty(self): overview = CustomerHealthService._calculate_overview_static([]) assert overview["total"] == 0 assert overview["active"] == 0 assert overview["watch"] == 0 assert overview["critical"] == 0 def test_overview_mixed(self, monkeypatch): class Row: def __init__(self, status, days_ago): self.status = status self.last_contact_at = datetime.utcnow() - timedelta(days=days_ago) rows = [ Row("customer", 1), Row("lead", 7), Row("negotiating", 14), Row("lost", 30), ] overview = CustomerHealthService._calculate_overview_static(rows) assert overview["total"] == 4 assert overview["active"] == 1