otko/tests/unit/test_architecture.py
smillmorel 9a1baa07c2 feat: live canvas backend switching (Options → Canvas Backend)
Both backends share one SelectionState owned by MainWindow and live
side by side in a QStackedWidget — switching is setCurrentWidget, so
no widget is destroyed mid-session (tearing a VTK window down leaves
dangling make-current callbacks). The choice persists in QSettings.
CanvasCapabilities declares per-backend gaps (force diagrams and
video export stay PyVista-only, both are documented and greyed out)
and the UI gates on capabilities rather than the backend name. The
architecture gate now allows the canvas_plotly package.
2026-09-16 18:43:59 -04:00

201 lines
7.5 KiB
Python

"""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 ``"<path>: <module>"`` 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