2026-09-16 12:03:07 -04:00
|
|
|
"""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
|
2026-09-16 18:43:59 -04:00
|
|
|
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.
|
2026-09-16 12:03:07 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import ast
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
SRC = ROOT / "src" / "otko"
|
2026-09-16 18:43:59 -04:00
|
|
|
CANVAS_DIRS = (
|
|
|
|
|
SRC / "views" / "canvas3d",
|
|
|
|
|
SRC / "views" / "canvas_plotly",
|
|
|
|
|
)
|
2026-09-16 12:03:07 -04:00
|
|
|
|
|
|
|
|
# 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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 18:43:59 -04:00
|
|
|
# Only the canvas packages may touch a 3D or plotting backend.
|
2026-09-16 12:03:07 -04:00
|
|
|
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, ...] = (),
|
2026-09-16 18:43:59 -04:00
|
|
|
exclude_dirs: tuple[Path, ...] = (),
|
2026-09-16 12:03:07 -04:00
|
|
|
) -> 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
|
2026-09-16 18:43:59 -04:00
|
|
|
if any(_is_within(path, directory) for directory in exclude_dirs):
|
2026-09-16 12:03:07 -04:00
|
|
|
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:
|
2026-09-16 18:43:59 -04:00
|
|
|
"""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}"
|
2026-09-16 12:03:07 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|