Three defects in the view-preservation path could leave the plot frozen (no updates, no orbit, stale colours): 1. The merge injected raw _fullLayout objects - including undefined when a push landed before the previous react resolved - and plotly validates layouts, so one bad value made every later react fail permanently. currentView() now deep-copies and validates eye/center/up and each range, and mergeView() is wrapped so it can never block an update. 2. Updates were not serialized: overlapping Plotly.react calls on one graph div left it unresponsive. otkoUpdate now queues and coalesces (one react at a time, latest payload wins), and logs instead of failing silently. 3. A style change re-framed the camera, so re-colouring yanked a view the user had orbited. Colour/opacity ride the traces and background/outline ride the layout, so set_style is now a non-framing push. Verified in the browser: a user orbit survives colour changes, 30 rapid preview updates land on the final value and stay responsive, and deleting the live camera no longer wedges the plot.
101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
"""Regression tests for the Plotly canvas push contract.
|
|
|
|
``Plotly.react`` resets any scene attribute the incoming layout omits, so a
|
|
data-only push must be flagged ``preserveView`` (the JS side then carries the
|
|
live camera and axis ranges forward). Only explicit re-frames may send the
|
|
computed framing. These tests capture the JavaScript the canvas emits instead
|
|
of driving the browser, so they stay fast and deterministic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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"
|
|
|
|
|
|
def _canvas_with_captured_js(qtbot): # type: ignore[no-untyped-def]
|
|
from otko.views.canvas_plotly import PlotlyCanvas
|
|
|
|
canvas = PlotlyCanvas()
|
|
qtbot.addWidget(canvas)
|
|
# Pretend the page finished loading, then capture instead of running JS.
|
|
canvas._ready = True
|
|
calls: list[str] = []
|
|
canvas._eval = lambda js: calls.append(js) # type: ignore[method-assign]
|
|
return canvas, calls
|
|
|
|
|
|
def _parse_call(call: str) -> tuple[dict, bool]:
|
|
"""Split a captured ``window.otkoUpdate(<json>, <flag>)`` call."""
|
|
body = call[len("window.otkoUpdate(") : -1]
|
|
literal, _sep, flag = body.rpartition(", ")
|
|
return json.loads(json.loads(literal)), flag == "true"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_framing_push_is_not_marked_preserve_view(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
|
|
assert calls, "show_project must push a figure"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is False, "initial push must be a re-frame"
|
|
# The framing push carries the camera and the padded ranges.
|
|
scene = payload["layout"]["scene"]
|
|
assert "camera" in scene
|
|
assert "range" in scene["xaxis"]
|
|
assert scene["xaxis"]["autorange"] is False
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_data_update_preserves_the_view(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
calls.clear()
|
|
|
|
canvas.selection.select_node(1) # data-only update
|
|
|
|
assert calls, "selection change must push"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is True, "data update must preserve the view"
|
|
assert "camera" not in payload["layout"]["scene"], "must not re-send the camera"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_view_presets_and_reset_re_frame(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
|
|
for action in (canvas.view_xy, canvas.view_xz, canvas.view_yz, canvas.reset_camera):
|
|
calls.clear()
|
|
action()
|
|
assert calls[-1].endswith(", false)"), f"{action.__name__} must re-frame"
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # type: ignore[no-untyped-def]
|
|
"""Re-colouring must not snap the camera back to the preset."""
|
|
from otko.views.canvas3d.style import RenderStyle
|
|
|
|
canvas, calls = _canvas_with_captured_js(qtbot)
|
|
canvas.show_project(load_project(EXAMPLES / "cantilever.osmodel"))
|
|
calls.clear()
|
|
|
|
canvas.set_style(RenderStyle(show_axis_outline=True))
|
|
|
|
assert calls, "a style change must push"
|
|
payload, preserve = _parse_call(calls[-1])
|
|
assert preserve is True, "colour changes must not re-frame the view"
|
|
assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied
|