"""Step definitions for coverage threshold enforcement feature.""" from __future__ import annotations import ast import re import tomllib from pathlib import Path from typing import Any import yaml from behave import given, then, when PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent @given("the noxfile.py exists") def step_noxfile_exists(context: Any) -> None: noxfile = PROJECT_ROOT / "noxfile.py" if not noxfile.is_file(): raise FileNotFoundError(f"noxfile.py not found at {noxfile}") context.noxfile_path = noxfile context.noxfile_content = noxfile.read_text(encoding="utf-8") @given("the pyproject.toml exists") def step_pyproject_exists(context: Any) -> None: pyproject = PROJECT_ROOT / "pyproject.toml" if not pyproject.is_file(): raise FileNotFoundError(f"pyproject.toml not found at {pyproject}") context.pyproject_path = pyproject context.pyproject_content = pyproject.read_text(encoding="utf-8") @given("the CI workflow file exists") def step_ci_workflow_exists(context: Any) -> None: ci_file = PROJECT_ROOT / ".forgejo" / "workflows" / "ci.yml" if not ci_file.is_file(): raise FileNotFoundError(f"CI workflow not found at {ci_file}") context.ci_file_path = ci_file context.ci_file_content = ci_file.read_text(encoding="utf-8") @when("I parse the COVERAGE_THRESHOLD constant from noxfile.py") def step_parse_threshold(context: Any) -> None: content = context.noxfile_content tree = ast.parse(content) threshold = None for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if ( isinstance(target, ast.Name) and target.id == "COVERAGE_THRESHOLD" and isinstance(node.value, ast.Constant) ): threshold = node.value.value if threshold is None: raise ValueError("COVERAGE_THRESHOLD constant not found in noxfile.py") context.coverage_threshold = threshold @then("the coverage threshold should be {expected:d}") def step_check_threshold(context: Any, expected: int) -> None: actual = context.coverage_threshold if actual != expected: raise AssertionError(f"Expected coverage threshold {expected}, got {actual}") @when("I read the coverage_report session source from noxfile.py") def step_read_coverage_session(context: Any) -> None: content = context.noxfile_content # Extract the coverage_report function body using regex # Find the function definition and capture everything until the next # top-level definition or end of file pattern = r"(def coverage_report\(.*?\n(?:(?: .*\n|[ \t]*\n)*))" match = re.search(pattern, content) if match is None: raise ValueError("coverage_report function not found in noxfile.py") context.coverage_session_source = match.group(1) @then("the session should contain a fail-under argument matching the threshold") def step_check_fail_under(context: Any) -> None: source = context.coverage_session_source # The session should reference COVERAGE_THRESHOLD in a fail-under argument if "fail-under" not in source: raise AssertionError( "coverage_report session does not contain --fail-under argument" ) if "COVERAGE_THRESHOLD" not in source: raise AssertionError( "coverage_report session does not reference COVERAGE_THRESHOLD constant" ) @when("I parse the coverage run configuration from pyproject.toml") def step_parse_coverage_run(context: Any) -> None: content = context.pyproject_content data = tomllib.loads(content) coverage_run = data.get("tool", {}).get("coverage", {}).get("run", {}) context.coverage_run_config = coverage_run @then("branch coverage should be enabled") def step_check_branch_coverage(context: Any) -> None: config = context.coverage_run_config if not config.get("branch", False): raise AssertionError("Branch coverage is not enabled in coverage.run config") @then('the source list should include "{source}"') def step_check_source_list(context: Any, source: str) -> None: config = context.coverage_run_config sources = config.get("source", []) if source not in sources: raise AssertionError(f"'{source}' not in coverage.run source list: {sources}") @then("the data file should be under the build directory") def step_check_data_file(context: Any) -> None: config = context.coverage_run_config data_file = config.get("data_file", "") if not data_file.startswith("build/"): raise AssertionError(f"Coverage data file '{data_file}' is not under build/") @when("I parse the coverage html configuration from pyproject.toml") def step_parse_coverage_html(context: Any) -> None: content = context.pyproject_content data = tomllib.loads(content) coverage_html = data.get("tool", {}).get("coverage", {}).get("html", {}) context.coverage_html_config = coverage_html @then("the html directory should be under the build directory") def step_check_html_dir(context: Any) -> None: config = context.coverage_html_config directory = config.get("directory", "") if not directory.startswith("build/"): raise AssertionError( f"Coverage HTML directory '{directory}' is not under build/" ) @when("I parse the coverage xml configuration from pyproject.toml") def step_parse_coverage_xml(context: Any) -> None: content = context.pyproject_content data = tomllib.loads(content) coverage_xml = data.get("tool", {}).get("coverage", {}).get("xml", {}) context.coverage_xml_config = coverage_xml @then("the xml output should be under the build directory") def step_check_xml_output(context: Any) -> None: config = context.coverage_xml_config output = config.get("output", "") if not output.startswith("build/"): raise AssertionError(f"Coverage XML output '{output}' is not under build/") @when("I parse the coverage job from the CI workflow") def step_parse_ci_coverage(context: Any) -> None: content = context.ci_file_content data = yaml.safe_load(content) jobs = data.get("jobs", {}) coverage_job = jobs.get("coverage", {}) if not coverage_job: raise ValueError("No 'coverage' job found in CI workflow") context.ci_coverage_job = coverage_job @then("the coverage job should run nox -s coverage_report") def step_check_ci_coverage_nox(context: Any) -> None: job = context.ci_coverage_job steps = job.get("steps", []) found = False for step in steps: run_cmd = step.get("run", "") if "nox -s coverage_report" in run_cmd: found = True break if not found: raise AssertionError( "CI coverage job does not contain 'nox -s coverage_report'" ) @then("the coverage job should depend on lint and typecheck") def step_check_ci_coverage_deps(context: Any) -> None: job = context.ci_coverage_job needs = job.get("needs", []) if "lint" not in needs: raise AssertionError( f"CI coverage job does not depend on 'lint': needs={needs}" ) if "typecheck" not in needs: raise AssertionError( f"CI coverage job does not depend on 'typecheck': needs={needs}" ) @then("the session should emit a COVERAGE OK summary line") def step_check_ok_summary(context: Any) -> None: source = context.coverage_session_source if "COVERAGE OK:" not in source: raise AssertionError( "coverage_report session does not emit 'COVERAGE OK:' summary" ) @then("the session should emit a COVERAGE FAILED summary line") def step_check_failed_summary(context: Any) -> None: source = context.coverage_session_source if "COVERAGE FAILED:" not in source: raise AssertionError( "coverage_report session does not emit 'COVERAGE FAILED:' summary" )