Skip to content

Commit f0a4896

Browse files
committed
Add comprehensive pytest test suite (693 tests)
6 test modules validating plugin structure, content, and consistency: - test_plugin_manifest: plugin.json keys, semver, paths, skill/rule counts - test_skills: frontmatter, required sections, content quality, internal links (28 skills) - test_rules: frontmatter, globs, body content (9 rules) - test_docs_consistency: version/count sync across README, CLAUDE.md, CONTRIBUTING, ROADMAP - test_internal_links: all relative markdown links resolve to existing files - test_roadmap: current version marker, table counts, completed section integrity CI integration: pytest runs in validate.yml after existing shell checks. Also adds Python cache entries to .gitignore. Made-with: Cursor
1 parent 15cea4f commit f0a4896

File tree

10 files changed

+702
-0
lines changed

10 files changed

+702
-0
lines changed

.github/workflows/validate.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,14 @@ jobs:
103103
rule_count=$(ls rules/*.mdc 2>/dev/null | wc -l)
104104
echo "Skills: $skill_count"
105105
echo "Rules: $rule_count"
106+
107+
- name: Set up Python
108+
uses: actions/setup-python@v5
109+
with:
110+
python-version: '3.12'
111+
112+
- name: Install test dependencies
113+
run: pip install -r requirements-test.txt
114+
115+
- name: Run test suite
116+
run: pytest tests/ -v --tb=short

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ ehthumbs.db
2626
dist/
2727
build/
2828
*.log
29+
30+
# Python
31+
__pycache__/
32+
*.pyc
33+
.pytest_cache/

requirements-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest>=8.0
2+
pyyaml>=6.0

tests/conftest.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Shared fixtures for Steam Cursor Plugin test suite."""
2+
3+
import json
4+
import re
5+
from pathlib import Path
6+
7+
import pytest
8+
import yaml
9+
10+
11+
REPO_ROOT = Path(__file__).resolve().parent.parent
12+
13+
SKILLS_DIR = REPO_ROOT / "skills"
14+
RULES_DIR = REPO_ROOT / "rules"
15+
PLUGIN_JSON = REPO_ROOT / ".cursor-plugin" / "plugin.json"
16+
17+
18+
def parse_frontmatter(text: str) -> tuple[dict, str]:
19+
"""Split YAML frontmatter from body. Returns (frontmatter_dict, body_str)."""
20+
if not text.startswith("---"):
21+
return {}, text
22+
parts = text.split("---", 2)
23+
if len(parts) < 3:
24+
return {}, text
25+
fm = yaml.safe_load(parts[1]) or {}
26+
body = parts[2].strip()
27+
return fm, body
28+
29+
30+
def get_markdown_sections(body: str) -> dict[str, str]:
31+
"""Extract H1/H2 sections from markdown body. Returns {heading: content}."""
32+
sections: dict[str, str] = {}
33+
current_heading = None
34+
current_lines: list[str] = []
35+
36+
for line in body.splitlines():
37+
m = re.match(r"^(#{1,2})\s+(.+)$", line)
38+
if m:
39+
if current_heading is not None:
40+
sections[current_heading] = "\n".join(current_lines).strip()
41+
current_heading = m.group(2).strip()
42+
current_lines = []
43+
else:
44+
current_lines.append(line)
45+
46+
if current_heading is not None:
47+
sections[current_heading] = "\n".join(current_lines).strip()
48+
49+
return sections
50+
51+
52+
# ── Fixtures ────────────────────────────────────────────────────────
53+
54+
55+
@pytest.fixture(scope="session")
56+
def repo_root():
57+
return REPO_ROOT
58+
59+
60+
@pytest.fixture(scope="session")
61+
def plugin_manifest():
62+
return json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
63+
64+
65+
@pytest.fixture(scope="session")
66+
def skill_dirs():
67+
"""Return list of skill directory paths that contain SKILL.md."""
68+
return sorted(
69+
d for d in SKILLS_DIR.iterdir()
70+
if d.is_dir() and (d / "SKILL.md").is_file()
71+
)
72+
73+
74+
@pytest.fixture(scope="session")
75+
def rule_files():
76+
"""Return list of .mdc rule file paths."""
77+
return sorted(RULES_DIR.glob("*.mdc"))
78+
79+
80+
@pytest.fixture(scope="session")
81+
def skill_count(skill_dirs):
82+
return len(skill_dirs)
83+
84+
85+
@pytest.fixture(scope="session")
86+
def rule_count(rule_files):
87+
return len(rule_files)
88+
89+
90+
@pytest.fixture(scope="session")
91+
def readme_text():
92+
return (REPO_ROOT / "README.md").read_text(encoding="utf-8")
93+
94+
95+
@pytest.fixture(scope="session")
96+
def claude_text():
97+
return (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
98+
99+
100+
@pytest.fixture(scope="session")
101+
def contributing_text():
102+
return (REPO_ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8")
103+
104+
105+
@pytest.fixture(scope="session")
106+
def changelog_text():
107+
return (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
108+
109+
110+
@pytest.fixture(scope="session")
111+
def roadmap_text():
112+
return (REPO_ROOT / "ROADMAP.md").read_text(encoding="utf-8")

tests/test_docs_consistency.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Verify version and count consistency across all documentation files."""
2+
3+
import re
4+
5+
from conftest import REPO_ROOT
6+
7+
8+
# ── Helpers ─────────────────────────────────────────────────────────
9+
10+
11+
def _extract_numbers(pattern, text):
12+
"""Extract all integer matches for a regex with one capture group."""
13+
return [int(m) for m in re.findall(pattern, text)]
14+
15+
16+
# ── Version Consistency ─────────────────────────────────────────────
17+
18+
19+
class TestVersionConsistency:
20+
def test_readme_badge_version(self, plugin_manifest, readme_text):
21+
version = plugin_manifest["version"]
22+
assert f"version-{version}-green" in readme_text, (
23+
f"README badge does not show version {version}"
24+
)
25+
26+
def test_roadmap_current_version(self, plugin_manifest, roadmap_text):
27+
version = plugin_manifest["version"]
28+
assert f"v{version}" in roadmap_text and "Current" in roadmap_text, (
29+
f"ROADMAP.md does not list v{version} as current"
30+
)
31+
32+
def test_claude_md_version(self, plugin_manifest, claude_text):
33+
version = plugin_manifest["version"]
34+
assert f"v{version}" in claude_text or version in claude_text, (
35+
f"CLAUDE.md does not mention version {version}"
36+
)
37+
38+
def test_changelog_has_current_version(self, plugin_manifest, changelog_text):
39+
version = plugin_manifest["version"]
40+
assert f"[{version}]" in changelog_text, (
41+
f"CHANGELOG.md has no entry for version {version}"
42+
)
43+
44+
45+
# ── Skill Count Consistency ─────────────────────────────────────────
46+
47+
48+
class TestSkillCountConsistency:
49+
def test_readme_skill_count(self, skill_count, readme_text):
50+
m = re.search(r"(?:\*\*|<strong>)(\d+)\s+skills(?:\*\*|</strong>)", readme_text)
51+
assert m, "README.md stats line missing skill count"
52+
assert int(m.group(1)) == skill_count, (
53+
f"README says {m.group(1)} skills, disk has {skill_count}"
54+
)
55+
56+
def test_claude_md_skill_count(self, skill_count, claude_text):
57+
m = re.search(r"Skills\s*\((\d+)\s+total\)", claude_text)
58+
assert m, "CLAUDE.md missing skills total in header"
59+
assert int(m.group(1)) == skill_count, (
60+
f"CLAUDE.md says {m.group(1)} skills, disk has {skill_count}"
61+
)
62+
63+
def test_contributing_skill_count(self, skill_count, contributing_text):
64+
m = re.search(r"\*\*(\d+)\s+skills\*\*", contributing_text)
65+
assert m, "CONTRIBUTING.md missing skill count"
66+
assert int(m.group(1)) == skill_count, (
67+
f"CONTRIBUTING.md says {m.group(1)} skills, disk has {skill_count}"
68+
)
69+
70+
71+
# ── Rule Count Consistency ──────────────────────────────────────────
72+
73+
74+
class TestRuleCountConsistency:
75+
def test_readme_rule_count(self, rule_count, readme_text):
76+
m = re.search(r"(?:\*\*|<strong>)(\d+)\s+rules(?:\*\*|</strong>)", readme_text)
77+
assert m, "README.md stats line missing rule count"
78+
assert int(m.group(1)) == rule_count, (
79+
f"README says {m.group(1)} rules, disk has {rule_count}"
80+
)
81+
82+
def test_claude_md_rule_count(self, rule_count, claude_text):
83+
m = re.search(r"Rules\s*\((\d+)\s+total\)", claude_text)
84+
assert m, "CLAUDE.md missing rules total in header"
85+
assert int(m.group(1)) == rule_count, (
86+
f"CLAUDE.md says {m.group(1)} rules, disk has {rule_count}"
87+
)
88+
89+
def test_contributing_rule_count(self, rule_count, contributing_text):
90+
m = re.search(r"\*\*(\d+)\s+rules\*\*", contributing_text)
91+
assert m, "CONTRIBUTING.md missing rule count"
92+
assert int(m.group(1)) == rule_count, (
93+
f"CONTRIBUTING.md says {m.group(1)} rules, disk has {rule_count}"
94+
)
95+
96+
97+
# ── Feature Table Coverage ──────────────────────────────────────────
98+
99+
100+
class TestFeatureTableCoverage:
101+
def _name_found_in_text(self, kebab_name: str, text_lower: str) -> bool:
102+
"""Check if a kebab-case component name appears in text under any variant."""
103+
if kebab_name in text_lower:
104+
return True
105+
stripped = kebab_name.replace("steam-", "").replace("steamworks-", "")
106+
words = stripped.split("-")
107+
spaced = " ".join(words)
108+
if spaced in text_lower:
109+
return True
110+
titled = " ".join(w.capitalize() for w in words)
111+
if titled.lower() in text_lower:
112+
return True
113+
hyphenated = "-".join(words)
114+
if hyphenated in text_lower:
115+
return True
116+
for i in range(len(words)):
117+
for j in range(i + 1, len(words) + 1):
118+
chunk = " ".join(words[i:j])
119+
if len(chunk) >= 6 and chunk in text_lower:
120+
return True
121+
return False
122+
123+
def test_all_skills_in_readme(self, skill_dirs, readme_text):
124+
readme_lower = readme_text.lower()
125+
for skill_dir in skill_dirs:
126+
name = skill_dir.name
127+
assert self._name_found_in_text(name, readme_lower), (
128+
f"skill '{name}' not found in README features table"
129+
)
130+
131+
def test_all_rules_in_readme(self, rule_files, readme_text):
132+
readme_lower = readme_text.lower()
133+
for rule_file in rule_files:
134+
stem = rule_file.stem
135+
assert self._name_found_in_text(stem, readme_lower), (
136+
f"rule '{stem}' not found in README features table"
137+
)

tests/test_internal_links.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Verify all relative markdown links in .md and .mdc files resolve to existing files."""
2+
3+
import re
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from conftest import REPO_ROOT
9+
10+
11+
LINK_PATTERN = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
12+
HTTP_PATTERN = re.compile(r"^https?://")
13+
ANCHOR_PATTERN = re.compile(r"^#")
14+
15+
16+
def _collect_md_files():
17+
"""Find all .md and .mdc files in the repo."""
18+
files = list(REPO_ROOT.rglob("*.md"))
19+
files += list(REPO_ROOT.rglob("*.mdc"))
20+
files = [f for f in files if ".git" not in f.parts and "node_modules" not in f.parts]
21+
return sorted(files)
22+
23+
24+
MD_FILES = _collect_md_files()
25+
26+
27+
def _extract_relative_links(filepath: Path) -> list[tuple[str, str, Path]]:
28+
"""Extract (link_text, raw_path, resolved_target) for relative links."""
29+
text = filepath.read_text(encoding="utf-8")
30+
results = []
31+
for match in LINK_PATTERN.finditer(text):
32+
link_text, raw_path = match.groups()
33+
if HTTP_PATTERN.match(raw_path):
34+
continue
35+
if ANCHOR_PATTERN.match(raw_path):
36+
continue
37+
if raw_path.startswith("mailto:"):
38+
continue
39+
clean = raw_path.split("#")[0].split("?")[0]
40+
if not clean:
41+
continue
42+
target = (filepath.parent / clean).resolve()
43+
results.append((link_text, raw_path, target))
44+
return results
45+
46+
47+
def _build_test_cases():
48+
"""Build (file, link_text, raw_path, target) tuples for parametrization."""
49+
cases = []
50+
for md_file in MD_FILES:
51+
for link_text, raw_path, target in _extract_relative_links(md_file):
52+
rel = md_file.relative_to(REPO_ROOT)
53+
cases.append(pytest.param(
54+
md_file, link_text, raw_path, target,
55+
id=f"{rel}::[{link_text}]({raw_path})"
56+
))
57+
return cases
58+
59+
60+
LINK_CASES = _build_test_cases()
61+
62+
63+
@pytest.mark.skipif(not LINK_CASES, reason="No relative links found")
64+
@pytest.mark.parametrize("md_file,link_text,raw_path,target", LINK_CASES)
65+
def test_relative_link_resolves(md_file, link_text, raw_path, target):
66+
assert target.exists(), (
67+
f"Broken link in {md_file.relative_to(REPO_ROOT)}: "
68+
f"[{link_text}]({raw_path}) -> {target}"
69+
)

0 commit comments

Comments
 (0)