otko/tests/gui/test_plotly_view_preservation.py

123 lines
4.5 KiB
Python
Raw Normal View History

"""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
@pytest.mark.gui
def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def]
"""A plotly failure inside the page must not be invisible from Python."""
from PySide6.QtWebEngineCore import QWebEnginePage
from otko.views.canvas_plotly.plotly_canvas import _LoggingPage
messages: list[tuple[str, str]] = []
page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg)))
levels = QWebEnginePage.JavaScriptConsoleMessageLevel
page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html")
page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html")
page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "")
assert messages == [
("error", "[web] plot.html:7 boom"),
("warning", "[web] plot.html:8 careful"),
("info", "[web] plotly:9 hello"),
]