"""Regression tests for the Plotly snap-hover marker. ``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. """ 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; } })()""" _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 }); })()""" _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; })()""" _EMIT_UNHOVER = "document.getElementById('plot').emit('plotly_unhover',{}), true" 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] """The original handler restyled on every hover/unhover event.""" 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 def test_snap_marker_shows_clears_and_tracks_the_camera(qtbot) -> None: # type: ignore[no-untyped-def] canvas = _open_canvas(qtbot, "basic_truss.osmodel") 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" canvas.set_snap_preview_enabled(True) qtbot.wait(150) _run_js(canvas, qtbot, _EMIT_SNAP_HOVER) 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 # Orbiting moves the target on screen without any plotly redraw. A top # view is used because a point near the scene centre projects to nearly # the same pixel from opposite diagonal views. _run_js( canvas, qtbot, "Plotly.relayout('plot',{'scene.camera.eye':{x:0,y:0,z:40}," "'scene.camera.up':{x:0,y:1,z:0}})", ) 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)