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.
This commit is contained in:
parent
804bf22f61
commit
9a1baa07c2
9 changed files with 442 additions and 38 deletions
122
tests/gui/test_canvas_backend_switch.py
Normal file
122
tests/gui/test_canvas_backend_switch.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""GUI tests for the live canvas-backend switch (PyVista ⇄ Plotly).
|
||||
|
||||
QSettings are injected per-test so a swap never leaks into the real user
|
||||
configuration or the next test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6")
|
||||
pytest.importorskip("pyvistaqt")
|
||||
pytest.importorskip("plotly")
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
|
||||
def _make_window(tmp_path, qtbot): # type: ignore[no-untyped-def]
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||
window = MainWindow(settings=settings)
|
||||
qtbot.addWidget(window)
|
||||
return window
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_default_backend_is_pyvista(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d import ModelCanvas
|
||||
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
assert window.canvas_backend() == "pyvista"
|
||||
assert isinstance(window._canvas, ModelCanvas)
|
||||
assert window._diagram_renderer is not None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_switch_to_plotly_preserves_selection(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window._vm.new_project()
|
||||
window._canvas.selection.select_node(2)
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
|
||||
assert window.canvas_backend() == "plotly"
|
||||
assert isinstance(window._canvas, PlotlyCanvas)
|
||||
# One shared SelectionState: the tree/table/properties stay bound.
|
||||
assert window._canvas.selection is window._selection
|
||||
assert window._canvas.selection.nodes == frozenset({2})
|
||||
# Both canvases live in the stack; the PyVista one was not destroyed.
|
||||
assert window._canvas_stack.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_plotly_gates_pyvista_only_features(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
assert window._canvas.capabilities.diagrams is True
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
caps = window._canvas.capabilities
|
||||
assert caps.diagrams is False
|
||||
assert caps.animation_export is False
|
||||
assert caps.extrusions is True and caps.labels is True
|
||||
|
||||
window._refresh_action_enablement()
|
||||
assert window._act_show_force_diagram.isEnabled() is False
|
||||
assert window._act_export_th_animation.isEnabled() is False
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_swapping_back_reuses_the_same_canvases(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
pyvista_canvas = window._canvas
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
plotly_canvas = window._canvas
|
||||
window.swap_canvas_backend("pyvista")
|
||||
|
||||
assert window._canvas is pyvista_canvas
|
||||
assert window._diagram_renderer is not None
|
||||
# No duplicate widgets accumulate across repeated swaps.
|
||||
window.swap_canvas_backend("plotly")
|
||||
assert window._canvas is plotly_canvas
|
||||
assert window._diagram_renderer is None # diagrams are PyVista-only
|
||||
assert window._canvas_stack.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_backend_choice_is_persisted(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window.swap_canvas_backend("plotly")
|
||||
assert window._settings.value("canvas/backend") == "plotly"
|
||||
|
||||
# A fresh window with the same settings boots straight into Plotly.
|
||||
from otko.views.canvas_plotly import PlotlyCanvas
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
settings = QSettings(str(tmp_path / "otko.ini"), QSettings.Format.IniFormat)
|
||||
second = MainWindow(settings=settings)
|
||||
qtbot.addWidget(second)
|
||||
assert second.canvas_backend() == "plotly"
|
||||
assert isinstance(second._canvas, PlotlyCanvas)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_working_plane_survives_the_swap(tmp_path, qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
window = _make_window(tmp_path, qtbot)
|
||||
window._vm.open(_example("basic_truss"))
|
||||
window._canvas.set_working_plane("XY", 0.0)
|
||||
|
||||
window.swap_canvas_backend("plotly")
|
||||
|
||||
assert window._canvas.working_plane_type() == "XY"
|
||||
assert window._canvas.working_plane_offset() == 0.0
|
||||
|
||||
|
||||
def _example(name: str) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(__file__).resolve().parents[2] / "examples" / f"{name}.osmodel")
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
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.
|
||||
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
|
||||
|
|
@ -16,7 +17,10 @@ from pathlib import Path
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC = ROOT / "src" / "otko"
|
||||
CANVAS = SRC / "views" / "canvas3d"
|
||||
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"}
|
||||
|
|
@ -32,7 +36,7 @@ CORE_FORBIDDEN = QT_ROOTS | {
|
|||
"matplotlib",
|
||||
}
|
||||
|
||||
# Only views/canvas3d/ may touch a 3D or plotting backend.
|
||||
# Only the canvas packages may touch a 3D or plotting backend.
|
||||
VTK_ROOTS = {"pyvista", "pyvistaqt", "vtk", "plotly"}
|
||||
|
||||
|
||||
|
|
@ -75,14 +79,14 @@ def _violations(
|
|||
forbidden: set[str],
|
||||
*,
|
||||
skip: tuple[Path, ...] = (),
|
||||
exclude_dir: Path | None = None,
|
||||
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 exclude_dir is not None and _is_within(path, exclude_dir):
|
||||
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}")
|
||||
|
|
@ -112,9 +116,9 @@ def test_architecture_views_no_solver() -> None:
|
|||
|
||||
|
||||
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}"
|
||||
"""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
|
||||
|
|
|
|||
Loading…
Reference in a new issue