Phase 1 - Foundation: - Add pytest, pyproject.toml, requirements-dev.txt - 593 smoke tests (324 syntax + 269 argparse --help) for all Python scripts - Harden CI: remove || true from compileall, expand to all 9 directories, add pytest step Phase 2 - Calculator unit tests: - RICE prioritizer: formula, prioritization, portfolio analysis, roadmap - DCF valuation: WACC, cash flow projections, terminal value, sensitivity - Financial ratios: profitability, liquidity, leverage, efficiency, valuation - Campaign ROI: metrics, benchmarks, portfolio summary - Funnel analyzer: stage metrics, bottleneck detection, segment comparison - OKR tracker: numeric/percentage/milestone/boolean KR scoring, status Phase 3 - Parser and compliance tests: - SEO checker: HTML parsing, scoring, heading hierarchy, alt text, word count - Commit linter: conventional commit regex, lint report, file input - GDPR compliance: pattern detection, file scanning, project analysis Phase 4 - Integration tests: - 671 skill integrity tests: frontmatter, H1 headings, scripts dirs, references Bug fixes found by tests: - Fix duplicate --reason argparse arg in document_version_control.py https://claude.ai/code/session_01MsVmZoAsPvLv7rAGDBGTbL
168 lines
6.2 KiB
Python
168 lines
6.2 KiB
Python
"""Unit tests for the SEO Checker."""
|
|
|
|
import sys
|
|
import os
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(
|
|
os.path.dirname(__file__), "..", "marketing-skill", "seo-audit", "scripts"
|
|
))
|
|
from seo_checker import SEOParser, analyze_html, compute_overall_score
|
|
|
|
|
|
class TestSEOParser:
|
|
def test_extracts_title(self):
|
|
p = SEOParser()
|
|
p.feed("<html><head><title>My Page Title</title></head></html>")
|
|
assert p.title == "My Page Title"
|
|
|
|
def test_extracts_meta_description(self):
|
|
p = SEOParser()
|
|
p.feed('<html><head><meta name="description" content="A great page"></head></html>')
|
|
assert p.meta_description == "A great page"
|
|
|
|
def test_extracts_og_description_fallback(self):
|
|
p = SEOParser()
|
|
p.feed('<html><head><meta property="og:description" content="OG desc"></head></html>')
|
|
assert p.meta_description == "OG desc"
|
|
|
|
def test_meta_description_takes_priority_over_og(self):
|
|
p = SEOParser()
|
|
p.feed('<head><meta name="description" content="Primary"><meta property="og:description" content="OG"></head>')
|
|
assert p.meta_description == "Primary"
|
|
|
|
def test_extracts_headings(self):
|
|
p = SEOParser()
|
|
p.feed("<h1>Main Title</h1><h2>Section 1</h2><h3>Subsection</h3>")
|
|
assert len(p.h_tags) == 3
|
|
assert p.h_tags[0] == (1, "Main Title")
|
|
assert p.h_tags[1] == (2, "Section 1")
|
|
assert p.h_tags[2] == (3, "Subsection")
|
|
|
|
def test_extracts_images(self):
|
|
p = SEOParser()
|
|
p.feed('<img src="photo.jpg" alt="A photo"><img src="icon.png">')
|
|
assert len(p.images) == 2
|
|
assert p.images[0]["alt"] == "A photo"
|
|
assert p.images[1]["alt"] is None
|
|
|
|
def test_extracts_links(self):
|
|
p = SEOParser()
|
|
p.feed('<a href="/internal">Click here</a><a href="https://example.com">External</a>')
|
|
assert len(p.links) == 2
|
|
assert p.links[0]["href"] == "/internal"
|
|
assert p.links[1]["href"] == "https://example.com"
|
|
|
|
def test_viewport_meta(self):
|
|
p = SEOParser()
|
|
p.feed('<meta name="viewport" content="width=device-width">')
|
|
assert p.viewport_meta is True
|
|
|
|
def test_ignores_script_content(self):
|
|
p = SEOParser()
|
|
p.feed("<body><script>var x = 1;</script><p>Real content</p></body>")
|
|
body_text = " ".join(p.body_text_parts)
|
|
assert "var x" not in body_text
|
|
assert "Real content" in body_text
|
|
|
|
|
|
class TestAnalyzeHTML:
|
|
def test_perfect_title(self):
|
|
# 55 chars is within 50-60 optimal range
|
|
title = "A" * 55
|
|
html = f"<html><head><title>{title}</title></head><body></body></html>"
|
|
result = analyze_html(html)
|
|
assert result["title"]["pass"] is True
|
|
assert result["title"]["score"] == 100
|
|
|
|
def test_missing_title(self):
|
|
result = analyze_html("<html><head></head><body></body></html>")
|
|
assert result["title"]["pass"] is False
|
|
assert result["title"]["score"] == 0
|
|
|
|
def test_one_h1_passes(self):
|
|
result = analyze_html("<h1>Title</h1>")
|
|
assert result["h1"]["pass"] is True
|
|
assert result["h1"]["count"] == 1
|
|
|
|
def test_multiple_h1s_fail(self):
|
|
result = analyze_html("<h1>First</h1><h1>Second</h1>")
|
|
assert result["h1"]["pass"] is False
|
|
assert result["h1"]["count"] == 2
|
|
|
|
def test_no_h1_fails(self):
|
|
result = analyze_html("<h2>No H1</h2>")
|
|
assert result["h1"]["pass"] is False
|
|
assert result["h1"]["count"] == 0
|
|
|
|
def test_heading_hierarchy_skip(self):
|
|
result = analyze_html("<h1>Title</h1><h3>Skipped H2</h3>")
|
|
assert result["heading_hierarchy"]["pass"] is False
|
|
assert len(result["heading_hierarchy"]["issues"]) == 1
|
|
|
|
def test_heading_hierarchy_ok(self):
|
|
result = analyze_html("<h1>Title</h1><h2>Section</h2><h3>Sub</h3>")
|
|
assert result["heading_hierarchy"]["pass"] is True
|
|
|
|
def test_image_alt_text_all_present(self):
|
|
result = analyze_html('<img src="a.jpg" alt="Photo"><img src="b.jpg" alt="Icon">')
|
|
assert result["image_alt_text"]["pass"] is True
|
|
assert result["image_alt_text"]["coverage_pct"] == 100.0
|
|
|
|
def test_image_alt_text_missing(self):
|
|
result = analyze_html('<img src="a.jpg" alt="Photo"><img src="b.jpg">')
|
|
assert result["image_alt_text"]["pass"] is False
|
|
assert result["image_alt_text"]["with_alt"] == 1
|
|
|
|
def test_no_images_passes(self):
|
|
result = analyze_html("<p>No images</p>")
|
|
assert result["image_alt_text"]["pass"] is True
|
|
|
|
def test_word_count_sufficient(self):
|
|
words = " ".join(["word"] * 350)
|
|
result = analyze_html(f"<body><p>{words}</p></body>")
|
|
assert result["word_count"]["pass"] is True
|
|
assert result["word_count"]["count"] >= 300
|
|
|
|
def test_word_count_insufficient(self):
|
|
result = analyze_html("<body><p>Too few words here</p></body>")
|
|
assert result["word_count"]["pass"] is False
|
|
|
|
def test_viewport_present(self):
|
|
result = analyze_html('<meta name="viewport" content="width=device-width">')
|
|
assert result["viewport_meta"]["pass"] is True
|
|
|
|
def test_viewport_missing(self):
|
|
result = analyze_html("<html><head></head></html>")
|
|
assert result["viewport_meta"]["pass"] is False
|
|
|
|
|
|
class TestComputeOverallScore:
|
|
def test_returns_integer(self):
|
|
html = "<html><head><title>Test</title></head><body><h1>Title</h1></body></html>"
|
|
results = analyze_html(html)
|
|
score = compute_overall_score(results)
|
|
assert isinstance(score, int)
|
|
assert 0 <= score <= 100
|
|
|
|
def test_demo_html_scores_reasonably(self):
|
|
from seo_checker import DEMO_HTML
|
|
results = analyze_html(DEMO_HTML)
|
|
score = compute_overall_score(results)
|
|
# Demo page is well-optimized, should score above 70
|
|
assert score >= 70
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_empty_html(self):
|
|
result = analyze_html("")
|
|
assert result["title"]["pass"] is False
|
|
assert result["h1"]["count"] == 0
|
|
|
|
def test_malformed_html(self):
|
|
# Should not crash on malformed HTML
|
|
result = analyze_html("<h1>Unclosed<h2>Nested badly")
|
|
assert isinstance(result, dict)
|
|
assert "h1" in result
|