otko/tests/gui/test_plotly_view_preservation.py
smillmorel 8f7bc3cf4f fix: keep the Plotly camera and axis ranges through data updates
Plotly.react resets any scene attribute the incoming layout omits, so every
selection change snapped the camera back to the default and re-fit the
ranges - the pinned framing was meaningless. Data-only pushes now pass
preserveView and html.py merges the live camera and axis ranges into the
incoming layout; only an explicit re-frame sends computed framing.

Also fixes the ordering bug where the framing flag was consumed before the
not-ready early return, which dropped the very first framing on the floor
(show_project runs before loadFinished).

Verified in the browser: user orbit + zoom survive selection and working
plane updates, while reset_camera re-frames. Regression-tested headlessly
by asserting the emitted preserveView flag.
2026-09-16 20:05:35 -04:00

97 lines
3.4 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_re_frames_for_layout_only_attrs(qtbot) -> None: # type: ignore[no-untyped-def]
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[-1].endswith(", false)"), "background/outline live in the layout"