"""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(, )`` 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 # plotly's camera lives in normalized scene units: the model is centred on # the origin and the eye is only a few units out (not a data-unit distance, # which parked the camera hundreds of units away and shrank the model). camera = scene["camera"] assert camera["center"] == {"x": 0.0, "y": 0.0, "z": 0.0} assert ( camera["eye"]["x"] ** 2 + camera["eye"]["y"] ** 2 + camera["eye"]["z"] ** 2 ) ** 0.5 < 20.0 @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"), ] @pytest.mark.gui def test_deformed_push_colours_by_scalar_and_ghosts_the_reference(qtbot) -> None: # type: ignore[no-untyped-def] """The canvas wiring drives the builder's contour + ghost overlays.""" import numpy as np from otko.services.deformation import DeformationSource from otko.views.canvas3d.model_renderer import RendererMode canvas, calls = _canvas_with_captured_js(qtbot) project = load_project(EXAMPLES / "cantilever.osmodel") canvas.set_project(project) ids = [node.id for node in project.nodes] source = DeformationSource( displacements=np.zeros((len(ids), 3)) + np.linspace(0, 1, len(ids))[:, None], node_id_to_row={nid: i for i, nid in enumerate(ids)}, ) canvas.set_mode(RendererMode.DEFORMED, source) canvas.set_show_undeformed(True) calls.clear() canvas.render() payload, _preserve = _parse_call(calls[-1]) names = {trace.get("name") for trace in payload["data"]} assert "undeformed-reference" in names nodes = next(t for t in payload["data"] if t.get("name") == "nodes") assert nodes["marker"]["showscale"] is True assert nodes["marker"]["colorbar"]["title"]["text"] == "|u|" @pytest.mark.gui def test_animation_push_carries_prebuilt_frames(qtbot) -> None: # type: ignore[no-untyped-def] """``animate`` must hand plotly a frame list, not drive Python re-renders.""" import numpy as np from otko.services.deformation import DeformationSource from otko.views.canvas_plotly.trace_builder import SceneOptions canvas, calls = _canvas_with_captured_js(qtbot) project = load_project(EXAMPLES / "cantilever.osmodel") canvas.set_project(project) canvas._scene = canvas._builder.build(project, SceneOptions()) ids = [node.id for node in project.nodes] options = [ SceneOptions( deformation=DeformationSource( displacements=np.full((len(ids), 3), float(k)), node_id_to_row={nid: i for i, nid in enumerate(ids)}, ) ) for k in (1.0, 2.0, 3.0) ] canvas.animate(options, duration_ms=25, loops=4) assert calls and calls[-1].startswith("window.otkoAnimate(") body = calls[-1][len("window.otkoAnimate(") :] literal, end = json.JSONDecoder().raw_decode(body) payload = json.loads(literal) assert len(payload["frames"]) == 3 assert payload["frames"][0]["data"], "frames must carry trace data" assert body[end:].startswith(", 25, 4, true")