180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import textwrap
|
|
|
|
from testing.quality_contract import load_contract
|
|
from testing.quality_coverage import run_check as run_coverage_check
|
|
from testing.quality_docs import run_check as run_docs_check
|
|
from testing.quality_hygiene import run_check as run_hygiene_check
|
|
|
|
|
|
def test_bundled_contract_exposes_local_and_jenkins_profiles():
|
|
contract = load_contract()
|
|
assert "local" in contract["profiles"]
|
|
assert "jenkins" in contract["profiles"]
|
|
assert contract["pytest_suites"]["unit"]["paths"]
|
|
|
|
|
|
def test_bundled_contract_keeps_monorepo_manifest_trees_out_of_hygiene_scope():
|
|
contract = load_contract()
|
|
required_doc_paths = {item["path"] for item in contract.get("required_docs", [])}
|
|
assert "AGENTS.md" not in required_doc_paths
|
|
|
|
globs = contract.get("hygiene", {}).get("line_limit_globs", [])
|
|
assert globs
|
|
for entry in globs:
|
|
assert entry.startswith(("testing/", "ci/", "scripts/tests/", "services/"))
|
|
assert "/scripts/" in entry or not entry.startswith("services/")
|
|
|
|
|
|
def test_docs_check_reports_missing_docstring_and_missing_path(tmp_path: Path):
|
|
module_path = tmp_path / "managed.py"
|
|
module_path.write_text("value = 1\n", encoding="utf-8")
|
|
(tmp_path / "README.md").write_text("repo docs\n", encoding="utf-8")
|
|
|
|
contract = {
|
|
"required_docs": [{"path": "README.md", "description": "Docs"}],
|
|
"managed_modules": ["managed.py"],
|
|
"lint_paths": ["missing-dir"],
|
|
"pytest_suites": {"unit": {"description": "Unit", "paths": ["missing-tests"]}},
|
|
"manual_scripts": [{"path": "missing-script.py", "description": "Manual"}],
|
|
}
|
|
|
|
issues = run_docs_check(contract, tmp_path)
|
|
|
|
assert "module docstring missing: managed.py" in issues
|
|
assert "contract path missing: missing-dir" in issues
|
|
assert "contract path missing: missing-tests" in issues
|
|
assert "contract path missing: missing-script.py" in issues
|
|
|
|
|
|
def test_docs_check_reports_missing_required_doc_metadata(tmp_path: Path):
|
|
(tmp_path / "README.md").write_text("", encoding="utf-8")
|
|
|
|
contract = {
|
|
"required_docs": [{"path": "README.md", "description": ""}, {"path": "missing.md", "description": "Missing"}],
|
|
"managed_modules": [],
|
|
"lint_paths": [],
|
|
"pytest_suites": {"unit": {"description": "", "paths": []}},
|
|
"manual_scripts": [{"path": "manual.py", "description": ""}],
|
|
}
|
|
|
|
issues = run_docs_check(contract, tmp_path)
|
|
|
|
assert "required doc empty: README.md" in issues
|
|
assert "required doc missing description: README.md" in issues
|
|
assert "required doc missing: missing.md" in issues
|
|
assert "pytest suite missing description: unit" in issues
|
|
assert "manual script missing description: manual.py" in issues
|
|
|
|
|
|
def test_hygiene_check_enforces_line_limit_and_name_rules(tmp_path: Path):
|
|
tests_dir = tmp_path / "tests"
|
|
tests_dir.mkdir()
|
|
bad_name = tests_dir / "bad-name.py"
|
|
bad_name.write_text("x = 1\n", encoding="utf-8")
|
|
long_file = tests_dir / "test_too_long.py"
|
|
long_file.write_text("line\n" * 4, encoding="utf-8")
|
|
|
|
contract = {
|
|
"hygiene": {
|
|
"max_lines": 3,
|
|
"line_limit_globs": ["tests/*.py"],
|
|
"naming_rules": [
|
|
{
|
|
"glob": "tests/*.py",
|
|
"pattern": r"^test_[a-z0-9_]+\.py$",
|
|
"description": "pytest files use test_*.py names.",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
issues = run_hygiene_check(contract, tmp_path)
|
|
|
|
assert any("file exceeds 3 LOC" in issue for issue in issues)
|
|
assert any("naming rule failed" in issue and "bad-name.py" in issue for issue in issues)
|
|
|
|
|
|
def test_coverage_check_enforces_per_file_floor(tmp_path: Path):
|
|
build_dir = tmp_path / "build"
|
|
build_dir.mkdir()
|
|
coverage_xml = build_dir / "coverage.xml"
|
|
coverage_xml.write_text(
|
|
textwrap.dedent(
|
|
"""\
|
|
<coverage>
|
|
<packages>
|
|
<package>
|
|
<classes>
|
|
<class filename="ok.py" line-rate="1.0" />
|
|
<class filename="low.py" line-rate="0.90" />
|
|
</classes>
|
|
</package>
|
|
</packages>
|
|
</coverage>
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
contract = {
|
|
"coverage": {
|
|
"minimum_percent": 95.0,
|
|
"tracked_files": ["ok.py", "low.py", "missing.py"],
|
|
}
|
|
}
|
|
|
|
issues = run_coverage_check(contract, tmp_path, coverage_xml)
|
|
|
|
assert "coverage below 95.0%: low.py (90.0%)" in issues
|
|
assert "coverage missing for tracked file: missing.py" in issues
|
|
|
|
|
|
def test_coverage_check_handles_missing_xml_and_source_root_mapping(tmp_path: Path):
|
|
missing_xml = tmp_path / "missing.xml"
|
|
assert run_coverage_check({"coverage": {"tracked_files": []}}, tmp_path, missing_xml) == [
|
|
"coverage xml missing: missing.xml"
|
|
]
|
|
|
|
source_dir = tmp_path / "pkg"
|
|
source_dir.mkdir()
|
|
(source_dir / "mapped.py").write_text("value = 1\n", encoding="utf-8")
|
|
coverage_xml = tmp_path / "coverage.xml"
|
|
coverage_xml.write_text(
|
|
textwrap.dedent(
|
|
f"""\
|
|
<coverage>
|
|
<sources>
|
|
<source>{source_dir}</source>
|
|
</sources>
|
|
<packages>
|
|
<package>
|
|
<classes>
|
|
<class filename="mapped.py" line-rate="1.0" />
|
|
<class filename="{(tmp_path / 'absolute.py').as_posix()}" line-rate="1.0" />
|
|
<class filename="skip.py" />
|
|
</classes>
|
|
</package>
|
|
</packages>
|
|
</coverage>
|
|
"""
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "absolute.py").write_text("value = 2\n", encoding="utf-8")
|
|
|
|
issues = run_coverage_check(
|
|
{
|
|
"coverage": {
|
|
"minimum_percent": 95.0,
|
|
"tracked_files": ["pkg/mapped.py", "absolute.py"],
|
|
}
|
|
},
|
|
tmp_path,
|
|
coverage_xml,
|
|
)
|
|
|
|
assert issues == []
|