diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py new file mode 100644 index 0000000..9281e67 --- /dev/null +++ b/tests/unit/test_architecture.py @@ -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 ``": "`` 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 diff --git a/tests/unit/test_ci_config.py b/tests/unit/test_ci_config.py new file mode 100644 index 0000000..063f9b3 --- /dev/null +++ b/tests/unit/test_ci_config.py @@ -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}" diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py new file mode 100644 index 0000000..f8c78e0 --- /dev/null +++ b/tests/unit/test_docs.py @@ -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 diff --git a/tests/unit/test_meta_gates.py b/tests/unit/test_meta_gates.py new file mode 100644 index 0000000..67ee070 --- /dev/null +++ b/tests/unit/test_meta_gates.py @@ -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") diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py new file mode 100644 index 0000000..7b16ea4 --- /dev/null +++ b/tests/unit/test_packaging.py @@ -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