166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
|
|
"""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
|
||
|
|
)
|