Se definen 12 tests desde las acceptance criteria antes de implementar. Todos fallan confirmando que el codigo de produccion no existe aun.
LeadScorer implementado iterativamente para pasar cada test. 12/12 tests en verde con la logica minima necesaria.
WEIGHTS extraido a clase. _company_score y _budget_score convertidas a tables. batch_score optimizado. Tests siguen en verde.
# Mapa de trazabilidad: Criterio de Aceptacion → Nombre de Test AC-01 Lead completo con datos validos → puntuacion 0-100 test_score_returns_int_between_0_and_100 AC-02 Lead None lanza ValueError test_score_raises_on_none_lead AC-03 Campo obligatorio faltante lanza ValueError test_score_raises_on_missing_required_field AC-04 activity_score > 100 se trunca a 100 test_activity_score_clamped_at_100 AC-05 Empresa 0 empleados = score de empresa 0.0 test_company_score_zero_employees AC-06 Empresa 1-9 empleados = 20.0 (micro) test_company_score_micro AC-07 Empresa 10-49 empleados = 50.0 (pequena) test_company_score_small AC-08 Empresa 50-199 empleados = 80.0 (mediana) test_company_score_medium AC-09 Empresa 200+ empleados = 100.0 (grande) test_company_score_large AC-10 Presupuesto negativo lanza ValueError test_budget_raises_on_negative AC-11 email_open_rate se normaliza maximo a 1.0 test_email_open_rate_clamped AC-12 batch_score omite leads invalidos con score=-1 test_batch_score_marks_invalid_as_minus_one
import pytest from src.lead_scoring import LeadScorer # ─── Fixture compartido ───────────────────────────────────────────────────── @pytest.fixture def scorer(): return LeadScorer() @pytest.fixture def valid_lead(): # Lead tipico de CultivaLeads: empresa mediana con buen engagement return { "activity_score": 75, "employees": 80, "budget_eur": 8500.0, "email_open_rate": 0.42, } # ─── AC-01: Puntuacion valida ─────────────────────────────────────────────── def test_score_returns_int_between_0_and_100(scorer, valid_lead): """AC-01: score devuelve entero en rango [0, 100].""" result = scorer.score(valid_lead) assert isinstance(result, int) assert 0 <= result <= 100 # Calculo esperado: 75*0.30 + 80*0.25 + 75*0.25 + 42*0.20 = 70 assert result == 70 # ─── AC-02 / AC-03: Validacion de entrada ────────────────────────────────── def test_score_raises_on_none_lead(scorer): """AC-02: lead=None debe lanzar ValueError.""" with pytest.raises(ValueError, match="cannot be None"): scorer.score(None) @pytest.mark.parametrize("missing_field", [ "activity_score", "employees", "budget_eur", "email_open_rate" ]) def test_score_raises_on_missing_required_field(scorer, valid_lead, missing_field): """AC-03: Campo obligatorio ausente lanza ValueError.""" del valid_lead[missing_field] with pytest.raises(ValueError, match=f"Missing required field: {missing_field}"): scorer.score(valid_lead) # ─── AC-04: Truncado de activity_score ───────────────────────────────────── def test_activity_score_clamped_at_100(scorer, valid_lead): """AC-04: activity_score > 100 se trata como 100.""" valid_lead["activity_score"] = 999 result = scorer.score(valid_lead) # Misma puntuacion que activity_score=100 valid_lead["activity_score"] = 100 expected = scorer.score(valid_lead) assert result == expected # ─── AC-05 a AC-09: Tramos de empresa ────────────────────────────────────── @pytest.mark.parametrize("employees,expected_raw", [ (0, 0.0), # AC-05: sin empleados (5, 20.0), # AC-06: micro (<10) (25, 50.0), # AC-07: pequena (10-49) (80, 80.0), # AC-08: mediana (50-199) (300, 100.0), # AC-09: grande (200+) ]) def test_company_score_tiers(scorer, employees, expected_raw): result = scorer._company_score(employees) assert result == expected_raw # ─── AC-10: Budget negativo ───────────────────────────────────────────────── def test_budget_raises_on_negative(scorer): """AC-10: Presupuesto negativo debe lanzar ValueError.""" with pytest.raises(ValueError, match="cannot be negative"): scorer._budget_score(-1) # ─── AC-11: Email open_rate normalizado ──────────────────────────────────── def test_email_open_rate_clamped(scorer, valid_lead): """AC-11: open_rate > 1.0 se normaliza a 1.0.""" valid_lead["email_open_rate"] = 5.0 # dato erroneo del CRM result = scorer.score(valid_lead) valid_lead["email_open_rate"] = 1.0 expected = scorer.score(valid_lead) assert result == expected # ─── AC-12: batch_score con invalidos ────────────────────────────────────── def test_batch_score_marks_invalid_as_minus_one(scorer): """AC-12: batch_score devuelve score=-1 para leads invalidos.""" leads = [ {"activity_score": 80, "employees": 120, "budget_eur": 15000, "email_open_rate": 0.55}, {"activity_score": 60}, # faltan campos — invalido None, # invalido ] results = scorer.batch_score(leads) assert results[0]["score"] > 0 assert results[1]["score"] == -1 assert results[2] == -1
========================= test session starts ========================= platform darwin -- Python 3.11.9, pytest-7.4.0 rootdir: /cultivaleads configfile: pyproject.toml plugins: cov-4.1.0 collected 15 items tests/test_lead_scorer.py ............... [100%] ---------- coverage: src/lead_scoring.py ----------- Name Stmts Miss Cover ----------------------------------------- src/lead_scoring.py 45 4 91% -------- Missing: lines 38, 52, 63, 71 --------- 38: budget < 1000 → return 10.0 [branch not hit] 52: batch None-item TypeErrror [implicit branch] 63: round edge case [float precision] 71: employees < 0 guard (dead?) [defensive branch] ================== 15 passed in 0.21s ==================
| Prioridad | Modulo | Funcion / Rama | Lineas sin cubrir | Cobertura actual | Impacto |
|---|---|---|---|---|---|
| P1 | lead_scoring.py |
_budget_score() — tramo <1000 |
38 | Leads con presupuesto bajo (freemium) | |
| P1 | lead_scoring.py |
batch_score() — None item implícito |
52 | TypeError en iteracion de None en batch | |
| P2 | lead_scoring.py |
score() — redondeo 0.5 edge case |
63 | Imprecision flotante en round() | |
| P2 | lead_scoring.py |
_company_score() — employees < 0 |
71 | Rama defensiva nunca invocada en prod |
# Mutante #7 — cambio de operador en _budget_score ORIGINAL: if budget_eur < 1000: return 10.0 MUTADO: if budget_eur <= 1000: return 10.0 # → Tests no distinguen budget=1000 exacto. Agregar: test_budget_score_at_boundary_1000 # Mutante #12 — cambio de comparador en _company_score ORIGINAL: if employees < 10: return 20.0 MUTADO: if employees <= 10: return 20.0 # → Agregar: test_company_score_exactly_10_employees (limite de tramo) # Mutante #19 — inversion de condicion en batch_score ORIGINAL: results.append({**lead, "score": -1}) MUTADO: results.append({**lead, "score": 0}) # → Agregar: test_batch_score_invalid_score_is_exactly_minus_one # Mutante #24 — reemplazo de ponderacion WEIGHTS ORIGINAL: "activity": 0.30 MUTADO: "activity": 0.31 # → Agregar: test_weights_sum_to_one + test_score_matches_manual_calculation
from hypothesis import given, strategies as st from hypothesis import settings from src.lead_scoring import LeadScorer scorer = LeadScorer() @given( activity=st.integers(min_value=0, max_value=500), employees=st.integers(min_value=1, max_value=10000), budget=st.floats(min_value=0, max_value=1_000_000), open_rate=st.floats(min_value=0.0, max_value=2.0), ) @settings(max_examples=500) def test_score_always_in_valid_range(activity, employees, budget, open_rate): """Propiedad: score siempre entre 0 y 100 con input valido.""" lead = { "activity_score": activity, "employees": employees, "budget_eur": budget, "email_open_rate": open_rate, } result = scorer.score(lead) assert 0 <= result <= 100 @given(st.lists(st.none() | st.fixed_dictionaries({ "activity_score": st.integers(min_value=0), "employees": st.integers(min_value=0), "budget_eur": st.floats(min_value=0), "email_open_rate": st.floats(min_value=0.0, max_value=1.0), }), min_size=1, max_size=50)) def test_batch_score_preserves_length(leads): """Propiedad: batch_score siempre devuelve len(output) == len(input).""" results = scorer.batch_score(leads) assert len(results) == len(leads)