test: meta gates for architecture, CI, docs and packaging
This commit is contained in:
parent
f1d58273b7
commit
ff09e38b7c
5 changed files with 388 additions and 0 deletions
197
tests/unit/test_architecture.py
Normal file
197
tests/unit/test_architecture.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Architecture and layering rules - see ``docs/architecture.md``.
|
||||
|
||||
The documented dependency stack is ``views -> viewmodels -> services -> core``;
|
||||
each layer may only import from itself or a lower layer, ``core`` stays free of
|
||||
Qt/solver/plotting libraries, and the rendering backend is confined to
|
||||
``views/canvas3d``. These tests never import the application modules; they read
|
||||
the source tree with ``ast`` so the import discipline can be checked without
|
||||
executing Qt or the solver. Directories that do not exist yet simply have
|
||||
nothing to walk, which trivially satisfies a "forbidden import" rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = ROOT / "src" / "otko"
|
||||
CANVAS = SRC / "views" / "canvas3d"
|
||||
|
||||
# Qt binding roots, in every spelling the project might use.
|
||||
QT_ROOTS = {"PySide6", "PyQt5", "PyQt6", "PySide"}
|
||||
|
||||
# core/ stays pure - no Qt and no solver/mesh/render libraries.
|
||||
CORE_FORBIDDEN = QT_ROOTS | {
|
||||
"openseespy",
|
||||
"opstool",
|
||||
"shapely",
|
||||
"vtk",
|
||||
"pyvista",
|
||||
"plotly",
|
||||
"matplotlib",
|
||||
}
|
||||
|
||||
# Only views/canvas3d/ may touch a 3D or plotting backend.
|
||||
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
|
||||
|
||||
|
||||
def _iter_python_files(root: Path) -> list[Path]:
|
||||
"""Return every ``*.py`` file below ``root``, or ``[]`` if it is absent."""
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(root.rglob("*.py"))
|
||||
|
||||
|
||||
def _root_imports(path: Path) -> set[str]:
|
||||
"""Return the top-level module names imported by a Python source file.
|
||||
|
||||
Handles ``import X``, ``import X.Y``, ``from X import ...`` and
|
||||
``from X.Y import ...``. Relative imports are ignored: they cannot reach
|
||||
a third-party package.
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
roots: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
roots.add(alias.name.split(".", 1)[0])
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
roots.add(node.module.split(".", 1)[0])
|
||||
return roots
|
||||
|
||||
|
||||
def _is_within(path: Path, directory: Path) -> bool:
|
||||
"""Return True when ``path`` lies inside ``directory`` (lexical check)."""
|
||||
try:
|
||||
path.relative_to(directory)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _violations(
|
||||
root: Path,
|
||||
forbidden: set[str],
|
||||
*,
|
||||
skip: tuple[Path, ...] = (),
|
||||
exclude_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Return ``"<path>: <module>"`` entries for every forbidden import found."""
|
||||
found: list[str] = []
|
||||
for path in _iter_python_files(root):
|
||||
if path in skip:
|
||||
continue
|
||||
if exclude_dir is not None and _is_within(path, exclude_dir):
|
||||
continue
|
||||
for module in sorted(_root_imports(path) & forbidden):
|
||||
found.append(f"{path.relative_to(ROOT)}: {module}")
|
||||
return found
|
||||
|
||||
|
||||
def test_architecture_core_has_no_qt_or_solver() -> None:
|
||||
"""core/ imports no Qt, solver, mesh, or plotting library."""
|
||||
violations = _violations(SRC / "core", CORE_FORBIDDEN)
|
||||
assert violations == [], f"forbidden imports in core/: {violations}"
|
||||
|
||||
|
||||
def test_architecture_services_no_qt_except_worker() -> None:
|
||||
"""No Qt in services/ except services/qt_workers.py."""
|
||||
violations = _violations(
|
||||
SRC / "services",
|
||||
QT_ROOTS,
|
||||
skip=(SRC / "services" / "qt_workers.py",),
|
||||
)
|
||||
assert violations == [], f"forbidden Qt imports in services/: {violations}"
|
||||
|
||||
|
||||
def test_architecture_views_no_solver() -> None:
|
||||
"""views/ imports neither openseespy nor opstool."""
|
||||
violations = _violations(SRC / "views", {"openseespy", "opstool"})
|
||||
assert violations == [], f"solver imports in views/: {violations}"
|
||||
|
||||
|
||||
def test_no_vtk_import_outside_canvas() -> None:
|
||||
"""3D/plot backends stay under views/canvas3d/."""
|
||||
violations = _violations(SRC, VTK_ROOTS, exclude_dir=CANVAS)
|
||||
assert violations == [], f"backend imports outside views/canvas3d/: {violations}"
|
||||
|
||||
|
||||
# The documented dependency stack; ``commands`` sits beside viewmodels (a VM
|
||||
# creates commands) and ``views`` is the top, so it is excluded from this map.
|
||||
_LAYER_ORDER = {"core": 0, "services": 1, "viewmodels": 2, "views": 3}
|
||||
|
||||
|
||||
def _internal_imports(path: Path) -> set[str]:
|
||||
"""Return the fully-qualified ``otko.*`` modules imported by a source file."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
found: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
found.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
found.add(node.module)
|
||||
return {module for module in found if module.startswith("otko.")}
|
||||
|
||||
|
||||
def test_architecture_lower_layers_do_not_import_higher() -> None:
|
||||
"""core/services/viewmodels never import a higher layer."""
|
||||
violations: list[str] = []
|
||||
for path in _iter_python_files(SRC):
|
||||
layer = path.relative_to(SRC).parts[0]
|
||||
if layer not in _LAYER_ORDER:
|
||||
continue
|
||||
for module in _internal_imports(path):
|
||||
target = module.split(".")[1] if "." in module else ""
|
||||
if target in _LAYER_ORDER and _LAYER_ORDER[target] > _LAYER_ORDER[layer]:
|
||||
violations.append(f"{path.relative_to(ROOT)}: {layer} -> {module}")
|
||||
assert violations == [], f"lower layer imports a higher layer: {violations}"
|
||||
|
||||
|
||||
def test_architecture_viewmodels_do_not_construct_widgets() -> None:
|
||||
"""viewmodels emit signals and import no Qt widgets."""
|
||||
violations = _violations(SRC / "viewmodels", {"QtWidgets"})
|
||||
assert violations == [], f"viewmodels import widgets: {violations}"
|
||||
for path in _iter_python_files(SRC / "viewmodels"):
|
||||
assert "PySide6.QtWidgets" not in path.read_text(encoding="utf-8"), path
|
||||
|
||||
|
||||
def test_architecture_report_is_qt_and_solver_free() -> None:
|
||||
"""report generation imports no Qt or solver (vacuous if absent)."""
|
||||
report = SRC / "services" / "report"
|
||||
violations = _violations(report, QT_ROOTS | {"openseespy", "opstool"})
|
||||
assert violations == [], f"report imports Qt/solver: {violations}"
|
||||
|
||||
|
||||
def test_architecture_project_vm_owns_the_undo_stack() -> None:
|
||||
"""Only project_vm.py holds a QUndoStack."""
|
||||
holders = [
|
||||
path.name
|
||||
for path in _iter_python_files(SRC / "viewmodels")
|
||||
if "QUndoStack" in path.read_text(encoding="utf-8")
|
||||
]
|
||||
assert holders == ["project_vm.py"], f"QUndoStack holders: {holders}"
|
||||
|
||||
|
||||
def test_architecture_no_environment_configuration() -> None:
|
||||
"""No env-var configuration anywhere in src/otko."""
|
||||
offenders = [
|
||||
str(path.relative_to(ROOT))
|
||||
for path in _iter_python_files(SRC)
|
||||
if "os.environ" in path.read_text(encoding="utf-8")
|
||||
or "os.getenv" in path.read_text(encoding="utf-8")
|
||||
]
|
||||
assert offenders == [], f"environment configuration in: {offenders}"
|
||||
|
||||
|
||||
def test_architecture_runner_splits_emit_and_run() -> None:
|
||||
"""Model emission and analysis live in separate modules."""
|
||||
emit_path = SRC / "services" / "_emit.py"
|
||||
run_path = SRC / "services" / "_run.py"
|
||||
assert emit_path.exists()
|
||||
assert run_path.exists()
|
||||
emit_source = emit_path.read_text(encoding="utf-8")
|
||||
run_source = run_path.read_text(encoding="utf-8")
|
||||
assert "class OpenSeesEmitter" in emit_source
|
||||
assert "class OpenSeesAnalysisRunner" in run_source
|
||||
assert "OpenSeesEmitter" in run_source
|
||||
39
tests/unit/test_ci_config.py
Normal file
39
tests/unit/test_ci_config.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Meta-gate checks for the CI workflow and pre-commit hook configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CI = ROOT / ".github" / "workflows" / "ci.yml"
|
||||
PRECOMMIT = ROOT / ".pre-commit-config.yaml"
|
||||
|
||||
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
|
||||
|
||||
|
||||
def test_ci_declares_all_jobs() -> None:
|
||||
"""The workflow exposes the lint/type/test split as separate jobs."""
|
||||
text = CI.read_text(encoding="utf-8")
|
||||
for job in ("lint:", "type:", "test-headless:", "test-gui:", "test-integration:"):
|
||||
assert f"\n {job}\n" in text, f"missing CI job: {job}"
|
||||
|
||||
|
||||
def test_ci_runs_the_headless_command() -> None:
|
||||
"""The headless job runs the unit + services, non-slow command verbatim."""
|
||||
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_precommit_declares_expected_hooks() -> None:
|
||||
"""The pre-commit config wires up the lint/format/type/hygiene hooks."""
|
||||
text = PRECOMMIT.read_text(encoding="utf-8")
|
||||
for hook in (
|
||||
"id: ruff",
|
||||
"id: ruff-format",
|
||||
"id: mypy",
|
||||
"id: trailing-whitespace",
|
||||
"id: end-of-file-fixer",
|
||||
"id: check-yaml",
|
||||
"id: check-added-large-files",
|
||||
"id: check-merge-conflict",
|
||||
):
|
||||
assert hook in text, f"missing pre-commit hook: {hook}"
|
||||
51
tests/unit/test_docs.py
Normal file
51
tests/unit/test_docs.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Documentation gates — assert the user-facing docs keep their key content.
|
||||
|
||||
Pure pathlib; no Qt or solver imports so this stays part of the fast unit
|
||||
suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
"""Return a file's UTF-8 text with normalized line endings."""
|
||||
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def test_quick_guide_exists_and_covers_core_topics() -> None:
|
||||
quick_guide = DOCS_DIR / "QUICK_GUIDE.md"
|
||||
assert quick_guide.is_file(), "docs/QUICK_GUIDE.md is missing"
|
||||
|
||||
lowered = _text(quick_guide).lower()
|
||||
for topic in ("cantilever", "report", "modal", "units", "undo"):
|
||||
assert topic in lowered, f"QUICK_GUIDE.md is missing {topic!r}"
|
||||
|
||||
|
||||
def test_contributing_describes_this_repo() -> None:
|
||||
text = _text(REPO_ROOT / "CONTRIBUTING.md")
|
||||
|
||||
for required in (
|
||||
"views",
|
||||
"viewmodels",
|
||||
"services",
|
||||
"core",
|
||||
"commands",
|
||||
"Conventional Commits",
|
||||
"feat:",
|
||||
"main",
|
||||
"pre-commit install",
|
||||
'pip install -e ".[gui,dev]"',
|
||||
"python -m otko",
|
||||
'pytest -m "not slow"',
|
||||
):
|
||||
assert required in text, f"CONTRIBUTING.md is missing {required!r}"
|
||||
|
||||
|
||||
def test_readme_links_quick_guide() -> None:
|
||||
readme = _text(REPO_ROOT / "README.md")
|
||||
assert "docs/QUICK_GUIDE.md" in readme
|
||||
29
tests/unit/test_meta_gates.py
Normal file
29
tests/unit/test_meta_gates.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Meta-gates tying the test markers to the CI commands that consume them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # Python < 3.11
|
||||
import tomli as tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
CI = ROOT / ".github" / "workflows" / "ci.yml"
|
||||
|
||||
HEADLESS_COMMAND = 'pytest tests/unit tests/services -m "not slow"'
|
||||
|
||||
|
||||
def test_slow_marker_is_declared() -> None:
|
||||
"""``[tool.pytest.ini_options]`` registers the ``slow`` marker."""
|
||||
with PYPROJECT.open("rb") as handle:
|
||||
config = tomllib.load(handle)
|
||||
markers = config["tool"]["pytest"]["ini_options"]["markers"]
|
||||
assert any(marker.startswith("slow:") for marker in markers), markers
|
||||
|
||||
|
||||
def test_ci_runs_the_headless_command() -> None:
|
||||
"""The headless CI command matches the marker the suite expects."""
|
||||
assert HEADLESS_COMMAND in CI.read_text(encoding="utf-8")
|
||||
72
tests/unit/test_packaging.py
Normal file
72
tests/unit/test_packaging.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Packaging invariants — version/solver single-sourcing and dependency hygiene.
|
||||
|
||||
Pure static checks over the repository files. No imports of :mod:`otko`,
|
||||
Qt or the solver stack, so this test runs anywhere (including Windows 3.12).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try: # Python 3.11+
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # pragma: no cover - Python 3.10 backport
|
||||
import tomli as tomllib
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_ROOT = REPO_ROOT / "src" / "otko"
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
_VERSION_RE = re.compile(r"^__version__\s*=", re.MULTILINE)
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
with PYPROJECT.open("rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
|
||||
|
||||
def _src_files() -> list[Path]:
|
||||
return sorted(SRC_ROOT.rglob("*.py"))
|
||||
|
||||
|
||||
def _dep_name(requirement: str) -> str:
|
||||
"""Return the lowercased distribution name from a PEP 508 requirement."""
|
||||
match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement)
|
||||
return match.group(1).lower().replace("_", "-") if match else ""
|
||||
|
||||
|
||||
def test_console_script_entrypoint() -> None:
|
||||
assert _pyproject()["project"]["scripts"]["otko"] == "otko.__main__:main"
|
||||
|
||||
|
||||
def test_project_version_is_dynamic() -> None:
|
||||
project = _pyproject()["project"]
|
||||
assert "version" not in project
|
||||
assert project["dynamic"] == ["version"]
|
||||
|
||||
|
||||
def test_single_version_assignment() -> None:
|
||||
hits = [path for path in _src_files() if _VERSION_RE.search(path.read_text(encoding="utf-8"))]
|
||||
assert len(hits) == 1, f"expected exactly one __version__ assignment, got {hits}"
|
||||
assert hits[0] == SRC_ROOT / "_const.py"
|
||||
|
||||
|
||||
def test_solver_version_literal_only_in_const() -> None:
|
||||
hits = [path for path in _src_files() if "3.8.0.0" in path.read_text(encoding="utf-8")]
|
||||
assert hits == [SRC_ROOT / "_const.py"], f"unexpected 3.8.0.0 occurrences: {hits}"
|
||||
|
||||
|
||||
def test_ruff_line_length_and_ignore() -> None:
|
||||
ruff = _pyproject()["tool"]["ruff"]
|
||||
assert ruff["line-length"] == 100
|
||||
assert "E501" in ruff["lint"]["ignore"]
|
||||
|
||||
|
||||
def test_no_phantom_scipy_pandas_dependencies() -> None:
|
||||
project = _pyproject()["project"]
|
||||
groups = [project.get("dependencies", [])]
|
||||
groups.extend(project.get("optional-dependencies", {}).values())
|
||||
declared = {_dep_name(req) for group in groups for req in group}
|
||||
assert "scipy" not in declared
|
||||
assert "pandas" not in declared
|
||||
Loading…
Reference in a new issue