2026-09-16 20:05:35 -04:00
|
|
|
"""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
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
# 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
|
2026-09-16 20:05:35 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
fix: stop the Plotly canvas wedging on rapid or overlapping updates
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.
2026-09-16 20:14:05 -04:00
|
|
|
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."""
|
2026-09-16 20:05:35 -04:00
|
|
|
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))
|
|
|
|
|
|
fix: stop the Plotly canvas wedging on rapid or overlapping updates
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.
2026-09-16 20:14:05 -04:00
|
|
|
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
|
2026-09-16 20:18:23 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"),
|
|
|
|
|
]
|
feat(plotly): opstool-style contour, loads, supports, ghost and animation
Bring the Plotly canvas in line with opstool's visualisation recipes.
Camera / interaction:
- frame the camera in plotly's normalized scene units, using the model
bounds (grid excluded); a data-unit eye rendered the model as a speck
- use turntable dragmode so Z stays up and the horizon stays level
- align mouse bindings with the PyVista/VTK backend and document them in
Help → Mouse Controls
Visualisation:
- colour deformed / modal shapes by response with a shared colourbar
- scale load arrows by |F|, tint per load pattern, hover the magnitude,
and fix cones rendering as oversized fins (sizemode scaled, not absolute)
- draw DOF-accurate support glyphs (ported from opstool; see NOTICE)
- add an undeformed-reference overlay on both canvas backends
- play mode shapes with plotly frame animation in-page
Tests: builder unit tests plus GUI regression tests for gestures, the
deformed push, the undeformed reference and animation payloads.
2026-09-16 23:10:22 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|