2026-09-16 20:52:40 -04:00
|
|
|
"""Regression tests for the Plotly snap-hover marker.
|
2026-09-16 20:33:30 -04:00
|
|
|
|
2026-09-16 20:52:40 -04:00
|
|
|
``Plotly.restyle`` on a gl3d plot costs ~90 ms even for a single trace (measured
|
|
|
|
|
in-page), so the marker used to lag far behind the cursor and stall orbiting.
|
|
|
|
|
It is now a pointer-events-none DOM overlay positioned by projecting the snapped
|
|
|
|
|
world point through gl-plot3d's own camera matrices, which costs microseconds
|
|
|
|
|
and issues no plotly calls at all.
|
2026-09-16 20:33:30 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
pytest.importorskip("PySide6")
|
|
|
|
|
pytest.importorskip("plotly")
|
|
|
|
|
|
|
|
|
|
from otko.services import load_project
|
|
|
|
|
|
|
|
|
|
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
|
|
|
|
|
|
|
|
|
|
#: 200 mouse-move events over a *non-snap* point with snapping armed.
|
|
|
|
|
_CHURN_PROBE = """(function(){
|
|
|
|
|
var gd = document.getElementById('plot');
|
|
|
|
|
var n = 0; var original = Plotly.restyle;
|
|
|
|
|
Plotly.restyle = function () { n++; return original.apply(Plotly, arguments); };
|
|
|
|
|
var point = { data: gd.data[0], customdata: [0, 0, 0] };
|
|
|
|
|
try {
|
|
|
|
|
for (var i = 0; i < 200; i++) {
|
|
|
|
|
gd.emit('plotly_hover', { points: [point] });
|
|
|
|
|
gd.emit('plotly_unhover', {});
|
|
|
|
|
}
|
|
|
|
|
return n;
|
|
|
|
|
} finally { Plotly.restyle = original; }
|
|
|
|
|
})()"""
|
|
|
|
|
|
2026-09-16 20:52:40 -04:00
|
|
|
_MARKER_STATE = """(function(){
|
|
|
|
|
var marker = document.getElementById('otko-snap-marker');
|
|
|
|
|
if (!marker) return 'missing';
|
|
|
|
|
return JSON.stringify({ display: marker.style.display, left: marker.style.left,
|
|
|
|
|
top: marker.style.top });
|
|
|
|
|
})()"""
|
2026-09-16 20:33:30 -04:00
|
|
|
|
|
|
|
|
_EMIT_SNAP_HOVER = """(function(){
|
|
|
|
|
var gd = document.getElementById('plot');
|
|
|
|
|
var snap = gd.data.filter(function(d){return d.meta && d.meta.kind === 'snap';})[0];
|
|
|
|
|
var point = { data: snap, customdata: [snap.x[0], snap.y[0], snap.z[0]] };
|
|
|
|
|
gd.emit('plotly_hover', { points: [point] });
|
|
|
|
|
return true;
|
|
|
|
|
})()"""
|
|
|
|
|
|
2026-09-16 20:52:40 -04:00
|
|
|
_EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true"
|
|
|
|
|
|
2026-09-16 20:33:30 -04:00
|
|
|
|
|
|
|
|
def _open_canvas(qtbot, name: str): # type: ignore[no-untyped-def]
|
|
|
|
|
from otko.views.canvas_plotly import PlotlyCanvas
|
|
|
|
|
|
|
|
|
|
canvas = PlotlyCanvas()
|
|
|
|
|
qtbot.addWidget(canvas)
|
|
|
|
|
canvas.show_project(load_project(EXAMPLES / name))
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.gui
|
|
|
|
|
def test_ordinary_mouse_movement_does_not_redraw(qtbot) -> None: # type: ignore[no-untyped-def]
|
2026-09-16 20:52:40 -04:00
|
|
|
"""The original handler restyled on every hover/unhover event."""
|
2026-09-16 20:33:30 -04:00
|
|
|
canvas = _open_canvas(qtbot, "space_frame_3d.osmodel")
|
|
|
|
|
|
|
|
|
|
restyles = _run_js(canvas, qtbot, _CHURN_PROBE)
|
|
|
|
|
|
|
|
|
|
assert restyles == 0, f"mouse movement caused {restyles} plot restyles"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.gui
|
2026-09-16 20:52:40 -04:00
|
|
|
def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def]
|
2026-09-16 20:33:30 -04:00
|
|
|
canvas = _open_canvas(qtbot, "basic_truss.osmodel")
|
2026-09-16 20:52:40 -04:00
|
|
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
|
|
|
|
|
|
|
|
|
# Nothing shows while snapping is off (the marker belongs to the draw tools).
|
|
|
|
|
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
|
|
|
|
qtbot.wait(150)
|
|
|
|
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
2026-09-16 20:33:30 -04:00
|
|
|
|
|
|
|
|
canvas.set_snap_preview_enabled(True)
|
2026-09-16 20:52:40 -04:00
|
|
|
qtbot.wait(150)
|
2026-09-16 20:33:30 -04:00
|
|
|
_run_js(canvas, qtbot, _EMIT_SNAP_HOVER)
|
2026-09-16 20:52:40 -04:00
|
|
|
qtbot.wait(200)
|
|
|
|
|
shown = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
|
|
|
|
assert shown["display"] == "block", "snap target not shown"
|
|
|
|
|
assert float(shown["left"].rstrip("px")) > 0
|
|
|
|
|
assert float(shown["top"].rstrip("px")) > 0
|
2026-09-16 20:33:30 -04:00
|
|
|
|
2026-09-16 20:52:40 -04:00
|
|
|
# Orbiting moves the target on screen without any plotly redraw.
|
|
|
|
|
_run_js(canvas, qtbot, "Plotly.relayout('plot',{'scene.camera.eye':{x:-9,y:6,z:4}})")
|
|
|
|
|
qtbot.wait(600)
|
|
|
|
|
moved = _json_state(_run_js(canvas, qtbot, _MARKER_STATE))
|
|
|
|
|
assert moved["display"] == "block"
|
|
|
|
|
assert (moved["left"], moved["top"]) != (shown["left"], shown["top"])
|
|
|
|
|
|
|
|
|
|
_run_js(canvas, qtbot, _EMIT_UNHOVER)
|
|
|
|
|
qtbot.wait(200)
|
|
|
|
|
assert _json_state(_run_js(canvas, qtbot, _MARKER_STATE))["display"] == "none"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_state(raw: object) -> dict:
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
assert isinstance(raw, str) and raw != "missing", f"marker element missing: {raw!r}"
|
|
|
|
|
return json.loads(raw)
|