"""Regression tests for the Plotly canvas mouse-gesture layer. plotly's built-in drag bindings (shift+left rotates, ctrl+left pans, middle zooms, right pans) disagree with the PyVista backend's VTK trackball style, so the Plotly canvas re-implements them in JS to match VTK: shift+left pan, ctrl+left spin, middle pan, right zoom. The page runs offscreen under QtWebEngine, where gl-plot3d's render loop is throttled and camera animations do not advance, so these tests spy on the gl-plot3d camera's ``view.pan`` / ``view.rotate`` calls and assert the mapping (and that plotly's own handler is suppressed for the gesture) rather than the resulting camera position. A full-camera smoke check is unnecessary here; plotly owns the maths. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest pytest.importorskip("PySide6") pytest.importorskip("plotly") from otko.services import load_project EXAMPLES = Path(__file__).resolve().parents[2] / "examples" #: Install spies on the live camera, run one synthetic drag, report the calls. #: ``keyBindingMode`` distinguishes our layer (false) from plotly's native #: handler ('rotate'), which calls the same ``view`` methods. _SPY_DRAG = """(function(){ var gd = document.getElementById('plot'); var s = gd._fullLayout.scene._scene; var cam = s.camera; var el = s.glplot.canvas; var r = el.getBoundingClientRect(); var x0 = r.left + r.width/2, y0 = r.top + r.height/2; var calls = []; var origPan = cam.view.pan; var origRotate = cam.view.rotate; cam.view.pan = function (t, dx, dy, dz) { calls.push(['pan', cam.keyBindingMode, dx, dy, dz]); }; cam.view.rotate = function (t, p, y, ro) { calls.push(['rotate', cam.keyBindingMode, p, y, ro]); }; function ev(t, x, y, b) { el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: __BUTTON__, buttons: b, shiftKey: __SHIFT__, ctrlKey: __CTRL__, altKey: __ALT__ })); } ev('mousedown', x0, y0, __BUTTONS__); ev('mousemove', x0 + 60, y0 + 40, __BUTTONS__); ev('mouseup', x0 + 60, y0 + 40, 0); cam.view.pan = origPan; cam.view.rotate = origRotate; return JSON.stringify({calls: calls, kbm: cam.keyBindingMode}); })()""" def _js_bool(value: bool) -> str: return "true" if value else "false" def _open_canvas(qtbot): # type: ignore[no-untyped-def] from otko.views.canvas_plotly import PlotlyCanvas canvas = PlotlyCanvas() qtbot.addWidget(canvas) canvas.show_project(load_project(EXAMPLES / "basic_truss.osmodel")) qtbot.waitUntil(lambda: canvas._ready, timeout=30000) qtbot.wait(1500) # let the first Plotly.react settle return canvas def _run_js(canvas, qtbot, script: str, timeout: int = 15000): # type: ignore[no-untyped-def] box: dict[str, object] = {} canvas._web.page().runJavaScript(script, lambda value: box.update(value=value)) qtbot.waitUntil(lambda: "value" in box, timeout=timeout) return box["value"] def _spy_drag( canvas, # type: ignore[no-untyped-def] qtbot, *, button: int, buttons: int, shift: bool = False, ctrl: bool = False, alt: bool = False, ) -> dict[str, Any]: script = ( _SPY_DRAG.replace("__BUTTON__", str(button)) .replace("__BUTTONS__", str(buttons)) .replace("__SHIFT__", _js_bool(shift)) .replace("__CTRL__", _js_bool(ctrl)) .replace("__ALT__", _js_bool(alt)) ) raw = _run_js(canvas, qtbot, script) assert isinstance(raw, str) return json.loads(raw) def _ours(calls: list[list[Any]], kind: str) -> list[list[Any]]: return [c for c in calls if c[0] == kind and c[1] is False] @pytest.mark.gui def test_gesture_bindings_match_vtk(qtbot) -> None: # type: ignore[no-untyped-def] """One page for all gestures: each QWebEngineView costs a WebGL context.""" canvas = _open_canvas(qtbot) # Shift+left pans (not plotly's rotate). result = _spy_drag(canvas, qtbot, button=0, buttons=1, shift=True) pans = _ours(result["calls"], "pan") assert pans and pans[0][3] != 0, result assert not _ours(result["calls"], "rotate"), result assert result["kbm"] == "rotate", "binding must be restored after the drag" # Middle drag pans, not zooms. result = _spy_drag(canvas, qtbot, button=1, buttons=4) pans = _ours(result["calls"], "pan") assert pans and pans[0][3] != 0 and pans[0][4] == 0, result assert result["kbm"] == "rotate", result # Right drag dollies (z pan), not plotly's pan. result = _spy_drag(canvas, qtbot, button=2, buttons=2) zooms = [c for c in _ours(result["calls"], "pan") if c[2] == 0 and c[3] == 0] assert zooms and zooms[0][4] != 0, result assert result["kbm"] == "rotate", result # Ctrl+left spins (pure roll). result = _spy_drag(canvas, qtbot, button=0, buttons=1, ctrl=True) rolls = [c for c in _ours(result["calls"], "rotate") if c[2] == 0 and c[3] == 0] assert rolls and rolls[0][4] != 0, result assert result["kbm"] == "rotate", result # Alt is not a VTK modifier: it falls back to a plain rotate. result = _spy_drag(canvas, qtbot, button=0, buttons=1, alt=True) rots = [c for c in _ours(result["calls"], "rotate") if c[4] == 0] assert rots and (rots[0][2] != 0 or rots[0][3] != 0), result assert result["kbm"] == "rotate", result # Plain left drag stays on plotly's native path (keyBindingMode 'rotate'). result = _spy_drag(canvas, qtbot, button=0, buttons=1) assert not _ours(result["calls"], "pan"), result assert not _ours(result["calls"], "rotate"), result assert any(c[1] == "rotate" for c in result["calls"]), result assert result["kbm"] == "rotate", result # In-page animation entry points are exposed to Python. assert ( _run_js( canvas, qtbot, "typeof window.otkoAnimate === 'function' && " "typeof window.otkoStopAnimation === 'function'", ) is True )