Co-Study4Grid / scripts /test_code_quality_report.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
8.41 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Unit tests for the code-quality reporter.
These exercise the smell-detection AST walker with hand-rolled source
fragments so the checks stay stable even as the repo evolves. The
integration side — "running the script against the whole repo does not
raise" — is covered by `scripts/check_code_quality.py` itself.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from code_quality_report import ( # noqa: E402
ANY_TYPE_RE,
BACKEND_SUPPRESSION_RE,
TS_IGNORE_RE,
_count_python_smells,
_cyclomatic_complexity,
_max_nesting,
build_report,
count_code_lines,
iter_backend_modules,
iter_frontend_sources,
)
def _first_fn(src: str) -> ast.AST:
tree = ast.parse(src)
return next(
n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
)
def _smells(src: str) -> tuple[int, int, int]:
return _count_python_smells(ast.parse(src))
def test_counts_bare_print_call():
src = "def f():\n print('hi')\n"
prints, tb, silent = _smells(src)
assert prints == 1 and tb == 0 and silent == 0
def test_does_not_count_print_inside_string_literal():
# A `print(...)` embedded in a multi-line string — e.g. the tkinter
# subprocess script in `main.py` — must not be flagged.
src = 'def f():\n script = """\nprint(path)\n"""\n return script\n'
prints, _, _ = _smells(src)
assert prints == 0
def test_counts_traceback_print_exc():
src = "import traceback\ndef f():\n try: pass\n except Exception:\n traceback.print_exc()\n"
_, tb, _ = _smells(src)
assert tb == 1
def test_counts_silent_except_pass():
src = "def f():\n try: pass\n except Exception:\n pass\n"
_, _, silent = _smells(src)
assert silent == 1
def test_logged_except_is_not_silent():
src = (
"import logging\n"
"logger = logging.getLogger(__name__)\n"
"def f():\n"
" try: pass\n"
" except Exception as e:\n"
" logger.debug('suppressed: %s', e)\n"
)
_, _, silent = _smells(src)
assert silent == 0
def test_bare_except_pass_also_counts():
src = "def f():\n try: pass\n except:\n pass\n"
_, _, silent = _smells(src)
assert silent == 1
def test_build_report_against_repo_root():
"""The whole-repo scan returns sane numbers and no exceptions."""
report = build_report()
assert report.backend.source_files >= 5
assert report.backend.total_lines > 0
assert report.frontend.source_files >= 10
assert report.frontend.total_lines > 0
# Gate invariants — if these regress, CI will fail on
# `check_code_quality.py` anyway.
assert report.backend.print_calls == 0
assert report.backend.traceback_prints == 0
assert report.backend.silent_excepts == 0
assert report.frontend.any_types == 0
assert report.frontend.ts_ignores == 0
# Ratcheted smells stay at or below their frozen ceilings.
assert report.backend.lint_suppressions <= 3
assert report.backend.functions_missing_return <= 60
assert report.frontend.weak_casts <= 12
assert report.frontend.record_str_unknown <= 45
# Return-annotation accounting is internally consistent.
assert report.backend.functions_annotatable > 0
assert 0 <= report.backend.functions_missing_return <= report.backend.functions_annotatable
# Complexity / nesting / code-line metrics are populated and sane.
assert report.backend.most_complex and report.backend.deepest_nested
assert 0 < report.backend.code_lines < report.backend.total_lines
assert 0 < report.frontend.code_lines < report.frontend.total_lines
# Per-function complexity / nesting respect the gate ceilings
# (transform_html / sanitize_for_json are the documented exemptions).
cc_exempt = {
"expert_backend/services/analysis/overflow_geo_transform.py::transform_html"
}
nest_exempt = {"expert_backend/services/sanitize.py::sanitize_for_json"}
for fn in report.backend.all_functions:
key = f"{fn.file}::{fn.name}"
if key not in cc_exempt:
assert fn.complexity <= 38, key
if key not in nest_exempt:
assert fn.max_nesting <= 8, key
def test_cyclomatic_complexity_counts_decision_points():
src = (
"def f(x):\n"
" if x and x > 0:\n" # If +1, BoolOp(and) +1
" return 1\n"
" for i in range(x):\n" # For +1
" pass\n"
" return [i for i in range(x) if i]\n" # comp +1, comp-if +1
)
assert _cyclomatic_complexity(_first_fn(src)) == 6
def test_max_nesting_tracks_deepest_block():
src = (
"def f(x):\n"
" if x:\n" # 1
" for i in x:\n" # 2
" while i:\n" # 3
" pass\n"
" return x\n"
)
assert _max_nesting(_first_fn(src)) == 3
def test_count_code_lines_excludes_blanks_and_comments(tmp_path):
py = tmp_path / "m.py"
py.write_text("# header\n\nx = 1\n\n# c\ny = 2\n")
assert count_code_lines(py, ("#",)) == 2
ts = tmp_path / "m.ts"
ts.write_text("// c\nconst a = 1;\n/* block\n still */\nconst b = 2;\n")
assert count_code_lines(ts, ("//",), block=("/*", "*/")) == 2
def test_any_type_re_catches_as_any_assertion_casts():
# `as any` (assertion cast) was a blind spot — only annotation
# positions used to be caught.
assert ANY_TYPE_RE.search("const x = y as any;")
assert ANY_TYPE_RE.search("function f(a: any) {}")
assert ANY_TYPE_RE.search("const xs: any[] = [];")
assert ANY_TYPE_RE.search("const g: Foo<any> = h;")
# `any` as a substring of an identifier must not match.
assert not ANY_TYPE_RE.search("const company = 1;")
def test_ts_ignore_re_catches_expect_error_and_nocheck():
assert TS_IGNORE_RE.search("// @ts-ignore")
assert TS_IGNORE_RE.search("// @ts-expect-error: not in global types")
assert TS_IGNORE_RE.search("// @ts-nocheck")
assert not TS_IGNORE_RE.search("// eslint-disable-next-line")
def test_backend_suppression_re_catches_noqa_and_type_ignore():
assert BACKEND_SUPPRESSION_RE.search("import x # noqa: F401")
assert BACKEND_SUPPRESSION_RE.search("z = w() # type: ignore[arg-type]")
assert not BACKEND_SUPPRESSION_RE.search("# an ordinary comment")
def test_backend_scan_covers_recommenders_but_not_setup_scripts():
"""The widened scan reaches `expert_backend/recommenders/` (gated for
the first time) while keeping setup-time / ad-hoc scripts and the test
suite out of scope."""
mods = {str(p) for p in iter_backend_modules()}
assert any(f"recommenders{Path('/').as_posix()}" in m.replace("\\", "/")
for m in mods), "recommenders/ package should now be scanned"
assert not any(m.endswith("install_graphviz.py") for m in mods)
assert not any(m.endswith("test_backend.py") for m in mods)
assert all("/tests/" not in m.replace("\\", "/") for m in mods)
def test_frontend_scan_excludes_test_infra():
"""Test files and the `src/test/` infra dir (which carries the
`@ts-expect-error` mocks) are not counted as product source."""
srcs = {str(p).replace("\\", "/") for p in iter_frontend_sources()}
assert srcs
assert all("/test/" not in s for s in srcs)
assert all(".test." not in s for s in srcs)
def test_all_functions_populated():
"""`all_functions` exposes every backend function for the gate."""
report = build_report()
# Every entry in the top-5 must also appear in `all_functions`.
assert len(report.backend.all_functions) >= len(report.backend.longest_functions)
top_keys = {(fn.file, fn.name) for fn in report.backend.longest_functions}
all_keys = {(fn.file, fn.name) for fn in report.backend.all_functions}
assert top_keys.issubset(all_keys)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))