system-prompts-and-models-o.../dealix/tests/unit/test_revenue_science.py
2026-05-01 14:03:52 +03:00

240 lines
8.4 KiB
Python

"""Smoke tests for Revenue Science (forecast/attribution/impact/churn/expansion)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from auto_client_acquisition.revenue_science.attribution import (
compute_first_touch,
compute_last_touch,
compute_linear,
compute_time_decay,
)
from auto_client_acquisition.revenue_science.causal_impact import simulate_impact
from auto_client_acquisition.revenue_science.churn_model import predict_churn
from auto_client_acquisition.revenue_science.expansion_model import predict_expansion
from auto_client_acquisition.revenue_science.forecast import (
STAGE_BASE_PROBABILITY,
compute_forecast,
)
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
# ── Forecast ─────────────────────────────────────────────────────
def test_forecast_empty_pipeline_returns_zero_decision():
f = compute_forecast(customer_id="c1", open_deals=[])
assert f.likely.revenue_sar == 0
assert any("ابدأ" in d for d in f.decisions_required_ar)
def test_forecast_likely_ge_worst():
deals = [
{"id": "d1", "stage": "demo", "value_sar": 100_000, "days_in_stage": 5},
{"id": "d2", "stage": "proposal", "value_sar": 200_000, "days_in_stage": 3},
{"id": "d3", "stage": "negotiation", "value_sar": 50_000, "days_in_stage": 2},
]
f = compute_forecast(customer_id="c1", open_deals=deals)
assert f.best.revenue_sar >= f.likely.revenue_sar
assert f.likely.revenue_sar >= f.worst.revenue_sar
def test_forecast_stalled_deal_gets_penalized():
fresh = [{"id": "d1", "stage": "discovery", "value_sar": 100_000, "days_in_stage": 2}]
stale = [{"id": "d1", "stage": "discovery", "value_sar": 100_000, "days_in_stage": 60}]
fresh_f = compute_forecast(customer_id="c1", open_deals=fresh)
stale_f = compute_forecast(customer_id="c1", open_deals=stale)
assert stale_f.likely.revenue_sar < fresh_f.likely.revenue_sar
def test_forecast_skips_won_lost():
deals = [
{"id": "d1", "stage": "won", "value_sar": 100_000, "days_in_stage": 0},
{"id": "d2", "stage": "lost", "value_sar": 50_000, "days_in_stage": 0},
{"id": "d3", "stage": "demo", "value_sar": 30_000, "days_in_stage": 5},
]
f = compute_forecast(customer_id="c1", open_deals=deals)
assert len(f.deals_breakdown) == 1
def test_stage_base_probability_monotonic():
"""Later stages should have higher probability."""
stages = ["new", "qualified", "discovery", "demo", "proposal", "negotiation", "verbal_yes"]
probs = [STAGE_BASE_PROBABILITY[s] for s in stages]
assert all(probs[i] <= probs[i + 1] for i in range(len(probs) - 1))
# ── Attribution ──────────────────────────────────────────────────
def test_first_touch_credits_first_channel():
deals = [{
"status": "won", "value_sar": 100_000,
"touchpoints": [
{"channel": "linkedin", "at": _now() - timedelta(days=20)},
{"channel": "whatsapp", "at": _now() - timedelta(days=10)},
{"channel": "email", "at": _now() - timedelta(days=2)},
],
}]
res = compute_first_touch(deals=deals)
assert res.by_channel == {"linkedin": 100_000}
def test_last_touch_credits_last_channel():
deals = [{
"status": "won", "value_sar": 100_000,
"touchpoints": [
{"channel": "linkedin", "at": _now() - timedelta(days=20)},
{"channel": "whatsapp", "at": _now() - timedelta(days=2)},
],
}]
res = compute_last_touch(deals=deals)
assert res.by_channel == {"whatsapp": 100_000}
def test_linear_splits_equally():
deals = [{
"status": "won", "value_sar": 100_000,
"touchpoints": [
{"channel": "linkedin", "at": _now() - timedelta(days=20)},
{"channel": "whatsapp", "at": _now() - timedelta(days=10)},
{"channel": "email", "at": _now() - timedelta(days=2)},
],
}]
res = compute_linear(deals=deals)
for ch in ("linkedin", "whatsapp", "email"):
assert abs(res.by_channel[ch] - 100_000 / 3) < 0.01
def test_time_decay_favors_recent():
deals = [{
"status": "won", "value_sar": 100_000, "closed_at": _now(),
"touchpoints": [
{"channel": "old", "at": _now() - timedelta(days=60)},
{"channel": "recent", "at": _now() - timedelta(days=2)},
],
}]
res = compute_time_decay(deals=deals, half_life_days=14)
assert res.by_channel["recent"] > res.by_channel["old"]
def test_attribution_skips_lost_deals():
deals = [{
"status": "lost", "value_sar": 100_000,
"touchpoints": [{"channel": "x", "at": _now()}],
}]
res = compute_linear(deals=deals)
assert res.total_revenue_sar == 0
# ── Causal Impact Simulator ──────────────────────────────────────
def test_simulate_impact_response_time_lift():
out = simulate_impact(
current_baseline_revenue_sar=100_000,
response_time_reduction_hours=4,
)
assert out.delta_sar > 0
assert "تقليل" in out.explanation_ar
def test_simulate_impact_no_changes_no_delta():
out = simulate_impact(current_baseline_revenue_sar=100_000)
assert out.delta_sar == 0
assert out.delta_pct == 0
def test_simulate_impact_warns_on_extreme_uplift():
out = simulate_impact(
current_baseline_revenue_sar=100_000,
response_time_reduction_hours=200, # absurd
extra_followup_touches=10,
shift_to_whatsapp_pct=1.0,
drop_n_sectors=10,
)
assert out.risk_warnings_ar # should have warnings
def test_simulate_impact_whatsapp_shift_high_risk_warning():
out = simulate_impact(
current_baseline_revenue_sar=100_000,
shift_to_whatsapp_pct=0.85,
)
assert any("opt-out" in w.lower() or "WhatsApp" in w for w in out.risk_warnings_ar)
# ── Churn ────────────────────────────────────────────────────────
def test_churn_high_for_disengaged():
p = predict_churn(
customer_id="c1",
days_since_last_login=45,
monthly_engagement_drop_pct=0.7,
support_tickets_open=3,
billing_failures_last_90d=2,
nps=5,
pipeline_added_drop_pct=0.6,
)
assert p.band == "critical"
assert p.score >= 0.65
assert p.drivers
def test_churn_low_for_healthy():
p = predict_churn(
customer_id="c1",
days_since_last_login=2,
monthly_engagement_drop_pct=0,
support_tickets_open=0,
billing_failures_last_90d=0,
nps=9,
pipeline_added_drop_pct=0,
)
assert p.band == "safe"
def test_churn_new_customer_cushion():
"""New customers get -0.1 score because of honeymoon period."""
new = predict_churn(customer_id="c1", days_since_last_login=20, months_as_customer=1)
old = predict_churn(customer_id="c1", days_since_last_login=20, months_as_customer=12)
assert new.score <= old.score
# ── Expansion ────────────────────────────────────────────────────
def test_expansion_high_for_growing_customer():
sig = predict_expansion(
customer_id="c1",
current_plan="Growth",
health_score=85,
monthly_engagement_growth_pct=0.4,
sectors_targeted=3,
pct_of_quota_used=0.9,
nps=9,
pipeline_added_growth_pct=0.4,
)
assert sig.likelihood >= 0.65
assert sig.recommended_plan in ("Scale", "Enterprise")
assert sig.estimated_upsell_sar > 0
def test_expansion_low_for_struggling():
sig = predict_expansion(
customer_id="c1",
current_plan="Growth",
health_score=40,
monthly_engagement_growth_pct=-0.2,
sectors_targeted=1,
pct_of_quota_used=0.3,
pipeline_added_growth_pct=-0.1,
)
assert sig.likelihood < 0.4
assert sig.estimated_upsell_sar == 0
def test_expansion_recommends_next_tier():
sig = predict_expansion(
customer_id="c1", current_plan="Starter", health_score=85,
pct_of_quota_used=0.95, nps=9, pipeline_added_growth_pct=0.5,
)
assert sig.recommended_plan == "Growth"