"""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 backends are confined to the canvas packages (``views/canvas3d``, ``views/canvas_plotly``). 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_DIRS = ( SRC / "views" / "canvas3d", SRC / "views" / "canvas_plotly", ) # 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 the canvas packages 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_dirs: tuple[Path, ...] = (), ) -> list[str]: """Return ``": "`` entries for every forbidden import found.""" found: list[str] = [] for path in _iter_python_files(root): if path in skip: continue if any(_is_within(path, directory) for directory in exclude_dirs): 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 the canvas packages.""" violations = _violations(SRC, VTK_ROOTS, exclude_dirs=CANVAS_DIRS) assert violations == [], f"backend imports outside the canvas packages: {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