"""Tests for backend/rules/conversion_rules.py""" from __future__ import annotations import pytest from backend.rules.conversion_rules import analyze from backend.models.issue import Category, Severity def _run(page_overrides: dict, threshold_overrides: dict | None = None) -> list: base = { "cta_texts": [], "form_field_counts": [], "trust_signal_count": 0, "has_testimonials": False, "has_hero": False, "hero_word_count": 0, "headings": [], "buttons": [], "has_pricing_section": False, } base.update(page_overrides) t = {"conversion": {"max_form_fields": 6, "min_trust_signals": 1, "hero_max_words": 150, "hero_min_words": 5}} if threshold_overrides: t["conversion"].update(threshold_overrides) return analyze(base, t) def rule_ids(issues): return {i.rule_id for i in issues} class TestCV1WeakCtaText: def test_fires_on_all_generic_ctAs(self): issues = _run({"cta_texts": ["click here", "submit", "learn more"]}) assert "CV1_weak_cta_text" in rule_ids(issues) def test_high_severity_when_no_strong_verb(self): issues = _run({"cta_texts": ["click here"]}) cv1 = next(i for i in issues if i.rule_id == "CV1_weak_cta_text") assert cv1.severity == Severity.HIGH def test_medium_severity_when_mixed(self): # "submit" is weak but "start free trial" has "start" (strong) issues = _run({"cta_texts": ["submit", "start free trial"]}) cv1 = next((i for i in issues if i.rule_id == "CV1_weak_cta_text"), None) if cv1: assert cv1.severity == Severity.MEDIUM def test_does_not_fire_on_strong_cta(self): issues = _run({"cta_texts": ["start free trial", "get started", "join now"]}) assert "CV1_weak_cta_text" not in rule_ids(issues) def test_does_not_fire_on_empty_ctas(self): issues = _run({"cta_texts": []}) assert "CV1_weak_cta_text" not in rule_ids(issues) def test_category_is_conversion(self): issues = _run({"cta_texts": ["submit"]}) cv1 = next((i for i in issues if i.rule_id == "CV1_weak_cta_text"), None) if cv1: assert cv1.category == Category.CONVERSION_OPTIMIZATION class TestCV2FormFriction: def test_fires_when_too_many_fields(self): issues = _run({"form_field_counts": [9]}) assert "CV2_form_friction" in rule_ids(issues) def test_not_fires_on_short_form(self): issues = _run({"form_field_counts": [4]}) assert "CV2_form_friction" not in rule_ids(issues) def test_fires_on_multiple_long_forms(self): issues = _run({"form_field_counts": [8, 10]}) assert "CV2_form_friction" in rule_ids(issues) def test_not_fires_on_empty_forms(self): issues = _run({"form_field_counts": []}) assert "CV2_form_friction" not in rule_ids(issues) def test_custom_threshold(self): issues = _run({"form_field_counts": [4]}, {"max_form_fields": 3}) assert "CV2_form_friction" in rule_ids(issues) def test_severity_is_high(self): issues = _run({"form_field_counts": [10]}) cv2 = next(i for i in issues if i.rule_id == "CV2_form_friction") assert cv2.severity == Severity.HIGH class TestCV3MissingTrustSignals: def test_fires_when_no_trust_signals(self): issues = _run({"trust_signal_count": 0, "has_testimonials": False}) assert "CV3_missing_trust_signals" in rule_ids(issues) def test_not_fires_when_has_trust_signals(self): issues = _run({"trust_signal_count": 2, "has_testimonials": False}) assert "CV3_missing_trust_signals" not in rule_ids(issues) def test_not_fires_when_has_testimonials(self): issues = _run({"trust_signal_count": 0, "has_testimonials": True}) assert "CV3_missing_trust_signals" not in rule_ids(issues) def test_severity_is_medium(self): issues = _run({"trust_signal_count": 0, "has_testimonials": False}) cv3 = next((i for i in issues if i.rule_id == "CV3_missing_trust_signals"), None) if cv3: assert cv3.severity == Severity.MEDIUM class TestCV4HeroClarity: def test_fires_missing_h1(self): issues = _run({"headings": []}) assert "CV4_missing_value_proposition" in rule_ids(issues) def test_not_fires_when_h1_present(self): issues = _run({"headings": [{"level": 1, "font_size_px": 32, "text": "Build Faster"}]}) assert "CV4_missing_value_proposition" not in rule_ids(issues) def test_fires_hero_word_overload(self): issues = _run({ "headings": [{"level": 1, "font_size_px": 32, "text": "headline"}], "has_hero": True, "hero_word_count": 200, }) assert "CV4_hero_word_overload" in rule_ids(issues) def test_not_fires_on_lean_hero(self): issues = _run({ "headings": [{"level": 1, "font_size_px": 32, "text": "headline"}], "has_hero": True, "hero_word_count": 40, }) assert "CV4_hero_word_overload" not in rule_ids(issues) def test_no_hero_fires_low_severity(self): issues = _run({ "headings": [{"level": 1, "font_size_px": 32, "text": "headline"}], "has_hero": False, "hero_word_count": 0, }) no_hero = next((i for i in issues if i.rule_id == "CV4_no_hero_section"), None) if no_hero: assert no_hero.severity == Severity.LOW def test_severity_h1_missing_is_high(self): issues = _run({"headings": []}) mv = next(i for i in issues if i.rule_id == "CV4_missing_value_proposition") assert mv.severity == Severity.HIGH class TestCV5PricingNoCta: def test_fires_when_pricing_but_no_buttons(self): issues = _run({"has_pricing_section": True, "buttons": []}) assert "CV5_pricing_no_cta" in rule_ids(issues) def test_not_fires_when_pricing_and_buttons(self): issues = _run({ "has_pricing_section": True, "buttons": [{"text": "Get Started", "height_px": 44, "background_color": "#007bff", "border_radius_px": 4, "has_focus_style": True, "padding_top_px": 8, "padding_right_px": 16, "padding_bottom_px": 8, "padding_left_px": 16, "color": "#fff"}], }) assert "CV5_pricing_no_cta" not in rule_ids(issues) def test_not_fires_when_no_pricing_section(self): issues = _run({"has_pricing_section": False, "buttons": []}) assert "CV5_pricing_no_cta" not in rule_ids(issues) def test_severity_is_high(self): issues = _run({"has_pricing_section": True, "buttons": []}) cv5 = next((i for i in issues if i.rule_id == "CV5_pricing_no_cta"), None) if cv5: assert cv5.severity == Severity.HIGH