From 2838b9710fb4548debaaf2e2f3f72bea8e6c0b72 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 8 Oct 2026 07:43:50 -0700 Subject: [PATCH 1/5] chore: remove server call to client on save_state --- src/ansys/visor/viewer/app/trame/local_app.py | 9 - src/ansys/visor/viewer/app/visor_vtk.py | 20 +- src/ansys/visor/viewer/app/visor_vtk_local.py | 1 - .../persist/scene/persisted_scene_state.py | 8 +- .../requests/visor_save_state_request.py | 15 - .../requests/visor_save_state_response.py | 37 -- src/ansys/visor/viewer/vtk/scene/base.py | 70 +-- .../visor/viewer/vtk/scene/local_scene.py | 18 +- .../viewer/vtk/scene/visor_frontend_bridge.py | 122 +---- tests/integration/test_python_api.py | 4 +- tests/integration/test_save_load_state.py | 122 ++--- tests/unit/app/test_local_app.py | 2 - .../unit/app/test_local_app_set_projection.py | 2 - tests/unit/app/test_local_app_sync_camera.py | 2 - ...test_local_app_sync_cross_section_plane.py | 1 - .../app/test_local_app_ui_panel_triggers.py | 1 - .../app/test_local_app_widget_triggers.py | 2 - tests/unit/app/test_visor_trame_app.py | 25 +- tests/unit/app/test_visor_vtk_local.py | 17 +- .../models/test_visor_save_state_request.py | 50 -- .../models/test_visor_save_state_response.py | 143 ----- tests/unit/vtk/scene/test_base.py | 491 ++++-------------- tests/unit/vtk/scene/test_local_scene.py | 7 +- .../vtk/scene/test_visor_frontend_bridge.py | 263 ---------- tests/unit/vtk/test_wire_format_identity.py | 7 +- 25 files changed, 199 insertions(+), 1240 deletions(-) delete mode 100644 src/ansys/visor/viewer/models/runtime/requests/visor_save_state_request.py delete mode 100644 src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py delete mode 100644 tests/unit/models/test_visor_save_state_request.py delete mode 100644 tests/unit/models/test_visor_save_state_response.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 78466dfb..0d5e153d 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -206,7 +206,6 @@ class LocalApp: server (Server): the Trame server instance Methods: get_scene_details_json: returns the scene details in JSON format - save_state_response: sends the current app state as payload pick_geometry: picks the geometry for rendering perf_report_wasm: reports the performance of the wasm update cycle perf_report_server_update: reports the performance of the server update cycle @@ -235,7 +234,6 @@ def __init__( self, server: Server, get_scene_details_json: callable, - handle_save_state_response: callable, standalone: bool = True, trame_logger: Logger | None = None, pick_geometry=None, @@ -244,8 +242,6 @@ def __init__( self.server = server # Callable to get the scene details in JSON format self._get_scene_details_json = get_scene_details_json - # Callable to handle the save state response from the frontend - self._handle_save_state_response = handle_save_state_response # Callable for sub-geometry picking (optional) self._pick_geometry = pick_geometry # Per-part visual state coordinator (see SceneMutationApi). The one @@ -310,11 +306,6 @@ def get_visor_scene_details_json(self): """ returns the scene details in JSON format. """ return self._get_scene_details_json() - @trigger("save_state_response") - def save_state_response(self, request_id: int, payload: dict): - """Frontend → Backend: the frontend sends the current app state as payload.""" - return self._handle_save_state_response(request_id, payload) - @trigger("pick_geometry") def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world_y: float, world_z: float): """Frontend → Backend: perform sub-geometry picking given a WASM actor/cell ID and world-space point.""" diff --git a/src/ansys/visor/viewer/app/visor_vtk.py b/src/ansys/visor/viewer/app/visor_vtk.py index 2d8442db..6231c12d 100644 --- a/src/ansys/visor/viewer/app/visor_vtk.py +++ b/src/ansys/visor/viewer/app/visor_vtk.py @@ -1,6 +1,5 @@ """Trame VTKlocal implementation of Visor class""" -import asyncio import traceback from abc import abstractmethod from functools import wraps @@ -365,29 +364,20 @@ def update_variables(self, dataset_id: int, variables: List[Dict[str, Any]]) -> logger.info(f"Variables for dataset {dataset_id} updated successfully") @require_server_on - async def save_state(self, state_dir: str, timeout: float = 5.0) -> None: + async def save_state(self, state_dir: str) -> None: """ Save the current viewer state to a directory. - Requests the runtime state from the frontend, converts it to persisted state, - and writes it as visor.json to state_dir. + Builds the persisted state from the scene's server-side records and + writes it as visor.json to state_dir. No client needs to be connected. The canonical snapshot path is always stamped onto each dataset's persisted state before writing, so load_state has one unambiguous place to look regardless of how the dataset was originally loaded. """ - try: - state = await self._scene.get_state(timeout=timeout) - except asyncio.TimeoutError as e: - msg = ( - "Timed out waiting for the frontend to respond to `getState`.\n" - "This typically means the Trame client is not connected/ready, or JS calls are not being processed.\n" - f"Waited {timeout} seconds." - ) - raise RuntimeError(msg) from e + state = self._scene.get_state() # Stamp the canonical snapshot path for every registered dataset. - # The registry is the source of truth for which datasets are loaded — not - # the frontend state, which only knows about visual/UI properties. + # The registry is the source of truth for which datasets are loaded. for dataset_name, dataset_state in state.scene.dataset_states.items(): dataset_state.serialized_dataset_path = str( self._file_io.get_persisted_dataset_path(state_dir, dataset_name) diff --git a/src/ansys/visor/viewer/app/visor_vtk_local.py b/src/ansys/visor/viewer/app/visor_vtk_local.py index 0a1258e8..6887ffe5 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_local.py +++ b/src/ansys/visor/viewer/app/visor_vtk_local.py @@ -42,7 +42,6 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> self._trame_app = LocalApp( self.server, lambda: self._scene.get_scene_details_json(), - lambda request_id, response: self._scene.handle_save_state_response(request_id, response), standalone, trame_logger=get_trame_logger(trame_log_dir), pick_geometry=lambda diff --git a/src/ansys/visor/viewer/models/persist/scene/persisted_scene_state.py b/src/ansys/visor/viewer/models/persist/scene/persisted_scene_state.py index 2e41bcde..38d4e1c9 100644 --- a/src/ansys/visor/viewer/models/persist/scene/persisted_scene_state.py +++ b/src/ansys/visor/viewer/models/persist/scene/persisted_scene_state.py @@ -85,10 +85,10 @@ def _derive_missing_variable_identity_fields(cls, value: Any) -> Any: """Fill absent identity fields on read, from the variable identifier. ``array_name``, ``type``, and ``num_components`` are required and stay - required: the model is shared with ``RuntimeSceneState.variable_states``, - so relaxing them would also relax the save-path coercion in - ``VisorSaveStateResponse._coerce_app_state``. Tolerance for older save - files lives here, on the container, and applies to the ingest boundary only. + required: the model is also built on the save path, by + ``VisorVariableRecord.to_variable_state``, so relaxing them would also + relax that construction. Tolerance for older save files lives here, on + the container, and applies to the ingest boundary only. """ if not isinstance(value, Mapping): return value diff --git a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_request.py b/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_request.py deleted file mode 100644 index 1d72098a..00000000 --- a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_request.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Model for sending a request to the client to retrieve the runtime viewer state.""" - -from pydantic import BaseModel, ConfigDict, Field - - -class VisorSaveStateRequest(BaseModel): - """ - Model for sending a request to the client to retrieve the runtime - viewer state, which is in turn used by the server to save the persisted state of the - Visor viewer. - """ - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - - # A unique identifier for the load state request. - request_id: int = Field(alias="requestId") diff --git a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py b/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py deleted file mode 100644 index a3929015..00000000 --- a/src/ansys/visor/viewer/models/runtime/requests/visor_save_state_response.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, field_validator - -from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState - - -class VisorSaveStateResponse(BaseModel): - """ - Model for the response from the client to receive the runtime - viewer state, which is in turn used by the server to save the persisted state of the - Visor viewer. - """ - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - - # A unique identifier for the load state request. - request_id: int = Field(alias="requestId") - app_state: RuntimeAppState = Field(alias="appState") - - @field_validator("app_state", mode="before") - @classmethod - def _coerce_app_state(cls, v: Any) -> Any: - """Coerce the app_state value to an Any type, allowing for flexible input formats.""" - # Accept already-built model - if isinstance(v, RuntimeAppState): - return v - - # Accept JSON string - if isinstance(v, str): - v = json.loads(v) - - # Accept dict and let Pydantic recursively build RuntimeAppState + nested models - if isinstance(v, dict): - return RuntimeAppState.model_validate(v) - - return v diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 62821897..738364e4 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -5,7 +5,7 @@ import threading from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List +from typing import Dict, List from trame_server import Server @@ -24,6 +24,7 @@ from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimePartProperties +from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.vtk.datasets.visor_dataset import VisorDataset @@ -34,9 +35,6 @@ from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate -if TYPE_CHECKING: - from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState - logger = VisorDefaultLogger(__name__) @@ -81,19 +79,20 @@ class VisorSceneBase(ABC): implementation (``self._vtk_renderer``): VTK pipeline objects, actor lifecycle, camera, and scene-state serialisation. - Subclasses must implement two abstract hooks that capture the difference - in *state authority* between rendering backends: + Subclasses must implement one abstract hook, which captures the difference + in *state delivery* between rendering backends: - * :meth:`_get_runtime_state_async` — wasm path does a frontend round-trip; - RCA/headless paths build state server-side. * :meth:`_push_runtime_state` — wasm path calls a JS ``set_state``; RCA path pushes camera onto ``vtkCamera``; headless is a no-op. + The saved state is built here, from the server's own records, for every + backend; no backend asks a client for it. + This separation means that adding a new rendering backend requires only: 1. A new :class:`IRenderer` implementation. - 2. A new :class:`VisorSceneBase` subclass that overrides the two hooks. + 2. A new :class:`VisorSceneBase` subclass that overrides the hook. """ _server: Server @@ -187,20 +186,9 @@ def __init__( self._state_mapper = VisorStateMapper(self._dataset_registry) # ========================================================================= - # Abstract hooks — subclasses differ on state authority + # Abstract hook — subclasses differ on state delivery # ========================================================================= - @abstractmethod - async def _get_runtime_state_async(self, timeout: float) -> "RuntimeAppState": - """ - Obtain the current runtime app state. - - * Wasm path: round-trip to the React frontend via - :class:`VisorFrontendBridge`. - * RCA / headless paths: build entirely from server-side VTK objects and - the dataset registry — no frontend call. - """ - @abstractmethod def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None: """ @@ -248,29 +236,25 @@ def list_all_dataset_info(self) -> dict[int, dict]: """Convenience: metadata snapshot for UI without parts.""" return self._dataset_registry.list_info() - async def get_state(self, timeout: float) -> PersistedViewerStateV1: + def get_state(self) -> PersistedViewerStateV1: """ - Capture the current viewer state and return it as a - :class:`PersistedViewerStateV1`. - - The frontend round trip remains the only source for everything the browser owns. - Per-part state is not: ``scene.dataset_states`` is replaced with the registry's - own runtime state before the persisted mapping runs. + Build the current viewer state from the server's own records and + return it as a :class:`PersistedViewerStateV1`. - The registry hands out live ``RuntimeDatasetState`` objects that the per-part - setters mutate from the trame daemon thread, so each one is deep-copied under - ``_vtk_lock``. The lock is taken after the ``await`` and never held across one. + No client is consulted, so a save works with no browser connected. + The runtime state starts empty and every one of its fields is assigned + below; a field left unassigned here would be saved at its model default. - The camera and the widget toggles are what the browser's reply does not - get to supply. Per-part state comes from the registry, the toggles from - this object's own store, and the camera from the renderer's record; the - reply is consulted for none of the three. + Per-part state comes from the registry. The registry hands out live + ``RuntimeDatasetState`` objects that the per-part setters mutate from the + trame daemon thread, so each one is deep-copied under ``_vtk_lock``. - The UI record is the fourth. ``runtime_state.ui`` is replaced - wholesale with a copy of this object's own record. + The widget toggles come from this object's own store, and the UI record + is a copy of this object's own record, so a panel trigger landing after + the call cannot mutate the state already returned. The camera comes from the renderer's record, which is authoritative, rather than - from the reply or from the pipeline ``vtkCamera``: the pipeline is the + from the pipeline ``vtkCamera``: the pipeline is the record's projection, and reading it back would re-import whatever drift VTK introduced -- ``ResetCamera`` rewrites ``clipping_range``. The assignment is unconditional. A ``None`` record means no camera was ever @@ -288,8 +272,11 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1: ``None`` means. In normal operation there is no ``None`` case to guard because the renderer seeds the record from its own widget the first time bounds are pushed. + + Variables and unit are the server's: the variable records and the + registry's unit. """ - runtime_state = await self._get_runtime_state_async(timeout) + runtime_state = RuntimeAppState() with self._vtk_lock: registry_dataset_states = { @@ -306,7 +293,6 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1: runtime_state.scene.edges_enabled = self._edges_enabled runtime_state.scene.bounding_box_enabled = self._bounding_box_enabled runtime_state.ui = self._ui_state.model_copy() - # Variables and unit are the server's, never the browser's reply. runtime_state.scene.variable_states = self._variable_records.model_copy(deep=True).variables runtime_state.scene.unit = self._dataset_registry.unit runtime_state.scene.dataset_states = registry_dataset_states @@ -419,10 +405,6 @@ def get_scene_details_json(self) -> str: """Return the VisorVtkPipelineState as JSON string.""" return json.dumps(self.get_scene_details().model_dump(exclude_none=True, by_alias=True)) - def handle_save_state_response(self, request_id: int, response: dict) -> None: - """Called by LocalApp trigger when the frontend responds.""" - self._frontend_bridge.resolve_save_state_response(request_id, response) - def clear(self): """Remove all actors from the renderer and reset the scene. diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index 1970e332..4dfffcd5 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -18,10 +18,9 @@ class VisorLocalScene(VisorSceneBase): """ Coordinator for a Visor viewer scene using the wasm/LocalView rendering path. - The React frontend is authority for camera state. :meth:`get_state` asks - the frontend for the current camera via :class:`VisorFrontendBridge` and - waits for the response. :meth:`apply_state` pushes the restored state back - to the frontend via a JS ``set_state`` call. + :meth:`get_state` is the base class's server-side build and asks the + frontend for nothing. :meth:`apply_state` pushes the restored state to + the frontend via a JS ``set_state`` call through :class:`VisorFrontendBridge`. This is the scene used by :class:`~ansys.visor.viewer.app.visor_vtk_local.VisorVTKLocal` @@ -42,14 +41,9 @@ def __init__(self, server: Server, dark_mode: bool = False): raise RuntimeError(msg) from e # ------------------------------------------------------------------------- - # State authority hooks — wasm: frontend round-trip + # State delivery hook — wasm: JS set_state # ------------------------------------------------------------------------- - async def _get_runtime_state_async(self, timeout: float) -> "RuntimeAppState": - """Ask the React frontend for the current app state (camera, UI, …).""" - response = await self._frontend_bridge.request_state(timeout=timeout) - return response.app_state - def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None: """ Flush the VTK window then push the restored state to the React frontend. @@ -70,10 +64,6 @@ def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None: # Wasm-specific helpers (not part of VisorSceneBase) # ------------------------------------------------------------------------- - def handle_save_state_response(self, request_id: int, response: dict) -> None: - """Called by LocalApp trigger when the frontend responds.""" - self._frontend_bridge.resolve_save_state_response(request_id, response) - def cleanup_state(self) -> None: """Remove transient wasm keys from trame server state.""" self._server.state.pop("wasm_ids", None) diff --git a/src/ansys/visor/viewer/vtk/scene/visor_frontend_bridge.py b/src/ansys/visor/viewer/vtk/scene/visor_frontend_bridge.py index 0df6d672..cb4a989c 100644 --- a/src/ansys/visor/viewer/vtk/scene/visor_frontend_bridge.py +++ b/src/ansys/visor/viewer/vtk/scene/visor_frontend_bridge.py @@ -1,52 +1,34 @@ """Frontend bridge. -This module contains :class:`FrontendBridge`, a small abstraction around making calls -into the frontend (via ``server.js_call``) and correlating async responses. - -Important: This class intentionally mirrors the previous logic that lived in -``base.py``. It aims for parity, not enhancements. +This module contains :class:`VisorFrontendBridge`, a small abstraction around making +calls into the frontend via ``server.js_call``. """ from __future__ import annotations -import asyncio -import threading -from typing import Dict, Optional - -from pydantic import ValidationError - from ansys.visor.viewer.core.visor_helpers import get_random_javascript_safe_id from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.runtime.requests.visor_load_state_request import VisorLoadStateRequest -from ansys.visor.viewer.models.runtime.requests.visor_save_state_request import VisorSaveStateRequest -from ansys.visor.viewer.models.runtime.requests.visor_save_state_response import VisorSaveStateResponse logger = VisorDefaultLogger(__name__) class VisorFrontendBridge: - """Bridge for JS -> Python request/response flows. + """Bridge for Python -> JS calls. - Currently used for the "getState" request, where the frontend later triggers - back a payload that we validate and resolve against the pending async waiter. + Sends the "setState" call that delivers a loaded state to the frontend. + The call is fire-and-forget: nothing waits for a reply. """ def __init__(self, server, ref_name: str): self._server = server self._ref_name = ref_name - self._pending_lock = threading.Lock() - # request_id (int) -> asyncio.Future[VisorSaveStateResponse] - self._pending_requests: Dict[int, asyncio.Future] = {} - # The asyncio loop that owns the pending futures (set on first request call) - self._pending_loop: Optional[asyncio.AbstractEventLoop] = None - def set_state(self, runtime_app_state) -> int: """Send a load-state request to the frontend. - This method intentionally mirrors the exact logic that previously lived in - ``VisorScene.apply_state`` (generate request_id, create VisorLoadStateRequest, - js_call "setState" with model_dump). + Generates a request id, wraps the state in a ``VisorLoadStateRequest`` + and sends it with js_call "setState". Returns ------- @@ -57,93 +39,3 @@ def set_state(self, runtime_app_state) -> int: load_request = VisorLoadStateRequest(request_id=request_id, app_state=runtime_app_state) self._server.js_call(self._ref_name, "setState", load_request.model_dump(by_alias=True)) return int(request_id) - - async def request_state(self, timeout: float) -> VisorSaveStateResponse: - """Request frontend state and await response. - - Mirrors the previous ``VisorScene.get_state`` request/await logic. - """ - request_id = get_random_javascript_safe_id() - - loop = asyncio.get_running_loop() - fut: asyncio.Future = loop.create_future() - with self._pending_lock: - # Remember which loop owns these futures so callbacks from other threads can complete them safely. - self._pending_loop = loop - self._pending_requests[int(request_id)] = fut - - save_request = VisorSaveStateRequest(request_id=request_id) - self._server.js_call(self._ref_name, "getState", save_request.model_dump(exclude_none=True, by_alias=True)) - - try: - response = await asyncio.wait_for(fut, timeout=timeout) - finally: - with self._pending_lock: - self._pending_requests.pop(int(request_id), None) - # If no pending requests remain, clear loop reference to avoid keeping it alive. - if not self._pending_requests: - self._pending_loop = None - - return response - - @staticmethod - def _threadsafe_set_exception( - loop: Optional[asyncio.AbstractEventLoop], - f: asyncio.Future, exc: BaseException - ) -> None: - if f is None or f.done(): - return - if loop is not None: - loop.call_soon_threadsafe(f.set_exception, exc) - else: - # Best-effort fallback (should only happen if no async waiter exists) - f.set_exception(exc) - - @staticmethod - def _threadsafe_set_result( - loop: Optional[asyncio.AbstractEventLoop], - f: asyncio.Future, - value: VisorSaveStateResponse, - ) -> None: - if f is None or f.done(): - return - if loop is not None: - loop.call_soon_threadsafe(f.set_result, value) - else: - # Best-effort fallback (should only happen if no async waiter exists) - f.set_result(value) - - def resolve_save_state_response(self, request_id: int, response: dict) -> None: - """Resolve a pending request from a frontend callback. - - This is meant to be called by the Trame trigger when the frontend replies. - """ - # Snapshot loop for threadsafe scheduling (callback may run off the loop thread) - with self._pending_lock: - loop = self._pending_loop - - try: - save_state_response = VisorSaveStateResponse.model_validate(response) - except ValidationError as e: - print(f"Invalid save state response received: {e}") - # Fail fast: if we can't parse the response, any awaiting request call - # would otherwise hang until timeout. Propagate the error to pending waiters. - with self._pending_lock: - fut = self._pending_requests.get(request_id) - self._threadsafe_set_exception(loop, fut, e) - raise ValueError(f"Invalid save state response: {e}") from e - except Exception as e: - print("Unexpected error while handling save state response") - with self._pending_lock: - fut = self._pending_requests.get(request_id) - self._threadsafe_set_exception(loop, fut, e) - raise - - - with self._pending_lock: - fut = self._pending_requests.get(request_id) - - if fut: - self._threadsafe_set_result(loop, fut, save_state_response) - else: - logger.warning(f"Late save_state_response with id={request_id} (no pending async waiter)") diff --git a/tests/integration/test_python_api.py b/tests/integration/test_python_api.py index 5678bb47..8f5bce65 100644 --- a/tests/integration/test_python_api.py +++ b/tests/integration/test_python_api.py @@ -109,11 +109,11 @@ def test_save_load_state(tmp_path): visor_instance = Visor() - # New behavior: save_state awaits a frontend-backed `scene.get_state()`. + # save_state reads the scene's server-side `get_state()`; no client is involved. # Keep the persisted state minimal and aligned with PersistedViewerStateV1 schema. mock_state = PersistedViewerStateV1() - async def fake_get_state(timeout: float = 5.0): + def fake_get_state(): return mock_state # `save_state` is async; it also requires the server to be "on" via decorator. diff --git a/tests/integration/test_save_load_state.py b/tests/integration/test_save_load_state.py index cde56c51..d54a2f17 100644 --- a/tests/integration/test_save_load_state.py +++ b/tests/integration/test_save_load_state.py @@ -30,7 +30,6 @@ """ import asyncio -import json import os from unittest.mock import MagicMock, patch @@ -50,11 +49,6 @@ from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 -from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import ( - RuntimeDatasetState, - RuntimePartProperties, -) -from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.vtk.io.file_to_dataset import file_to_dataset from ansys.visor.viewer.vtk.io.visor_file_io import VisorFileIO @@ -106,15 +100,11 @@ def iface(): # ------------------------------------------------------------------ # # Camera literals # -# Hand-written, and different in every field between the two, so the saved -# file names its source by value rather than by a recorded call. No value -# here originates from VTK. +# Hand-written. No value here originates from VTK. # ------------------------------------------------------------------ # RECORD_CAMERA_POSITION = [11.0, 12.0, 13.0] RECORD_CAMERA_CLIPPING_RANGE = [17.0, 18.0] -REPLY_CAMERA_POSITION = [21.0, 22.0, 23.0] -REPLY_CAMERA_CLIPPING_RANGE = [27.0, 28.0] def _record_camera() -> VisorCameraState: @@ -130,19 +120,6 @@ def _record_camera() -> VisorCameraState: ) -def _reply_camera() -> VisorCameraState: - """The camera the browser answers getState with.""" - return VisorCameraState( - position=REPLY_CAMERA_POSITION, - focal_point=[24.0, 25.0, 26.0], - view_up=[1.0, 0.0, 0.0], - clipping_range=REPLY_CAMERA_CLIPPING_RANGE, - parallel_projection=False, - view_angle=32.0, - parallel_scale=29.0, - ) - - # ================================================================== # # write_dataset / read_dataset round-trips # ================================================================== # @@ -429,15 +406,16 @@ def test_stale_snapshot_deleted_after_dataset_removed(self, file_io, tmp_path): class TestRegistrySourcedPartState: """The server's own registry is the authority for per-part state across a - save/load round trip, with the browser never consulted. + save/load round trip, with no browser involved. - The two directions are separate tests: the save asserts against the raw - ``visor.json`` (parts keyed by **name**), the load asserts against the - registry. Neither uses the other's output, so a failure names one side. + The two directions are separate tests: the save asserts against the + persisted state ``get_state`` returns (parts keyed by **name**), the load + asserts against the registry. Neither uses the other's output, so a + failure names one side. """ - def test_saved_visor_json_carries_registry_part_state_keyed_by_name(self, iface, tmp_path): - """save_state writes the registry's per-part state, not the frontend's.""" + def test_get_state_carries_registry_part_state_keyed_by_name(self, iface): + """get_state returns the registry's per-part state, keyed by name.""" data = file_to_dataset(_vtp_path()) dataset_id = iface._scene.add_dataset(data, ExtendedMetadata(name="plate", unit="m")) part_id = iface._scene.datasets[dataset_id].part_index.part_ids[0] @@ -447,41 +425,28 @@ def test_saved_visor_json_carries_registry_part_state_keyed_by_name(self, iface, iface._scene.set_part_visibility(part_id, False) iface._scene.set_part_diffuse_color(part_id, [1.0, 0.0, 0.0]) - # The browser answers getState with contradictory per-part values for - # the same dataset. A pass therefore proves the file came from the - # registry rather than from the frontend round trip. - frontend_state = RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={ - dataset_id: RuntimeDatasetState( - id=dataset_id, - part_states={ - part_id: RuntimePartProperties(id=part_id, opacity=0.99, visible=True) - }, - ) - }, - variable_states={}, - ) + persisted = iface._scene.get_state() + + # Parts are keyed by name, not id; a non-composite dataset has one + # part, named after the dataset. + part = persisted.scene.dataset_states["plate"].parts["plate"] + assert part.opacity == 0.25 + assert part.visible is False + assert part.diffuse_rgb == [1.0, 0.0, 0.0] - async def _frontend_round_trip(timeout: float = 5.0): - return frontend_state + def test_save_state_writes_the_state_file_with_no_browser_connected(self, iface, tmp_path): + """save_state writes visor.json with no client connected. - iface._scene._get_runtime_state_async = _frontend_round_trip + The fixture's trame server is never started, so no browser is attached + to it; a save that waited on a client for any part of the state would + never complete. + """ iface._server_manager = MagicMock() iface._server_manager.running = True asyncio.run(iface.save_state(str(tmp_path))) - with open(os.path.join(str(tmp_path), "visor.json"), "r") as fh: - written = json.load(fh) - - # Parts are keyed by name, not id; a non-composite dataset has one - # part, named after the dataset. - part = written["scene"]["dataset_states"]["plate"]["parts"]["plate"] - assert part["opacity"] == 0.25 - assert part["visible"] is False - assert part["diffuse_rgb"] == [1.0, 0.0, 0.0] + assert (tmp_path / "visor.json").exists() def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, tmp_path): """load_state populates the registry itself, with no browser involved.""" @@ -590,48 +555,23 @@ def test_reloading_a_colored_part_restores_its_file_range(self, file_io, iface, assert held.magnitude_range == (1.0, 2.0) assert held.default_ranges[0] == (-110.0, 110.0) - def test_saved_visor_json_carries_the_camera_record_not_the_browsers(self, iface, tmp_path): - """save_state writes the server's camera record, not the browser's reply. + def test_get_state_carries_the_camera_record(self, iface): + """get_state returns the server's camera record. The record is seeded through ``sync_camera``, which also projects onto the pipeline camera, so record and pipeline hold the same values here. - This case therefore discriminates the **record from the browser's - reply** and nothing more; separating the record from its own pipeline - projection is done in tests/unit/vtk/scene/test_base.py, against a - renderer double whose pipeline read answers with different numbers. + Separating the record from its own pipeline projection is done in + tests/unit/vtk/scene/test_base.py, against a renderer double whose + pipeline read answers with different numbers. No dataset is added, so ``finalize_scene``'s reset never runs and cannot overwrite the seeded record with a VTK-derived one. """ iface._scene._renderer.sync_camera(_record_camera()) - # The browser answers getState with a different camera in every field. - # A pass therefore proves the file came from the record. - frontend_state = RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - camera=_reply_camera(), - variable_states={}, - ) - - async def _frontend_round_trip(timeout: float = 5.0): - return frontend_state - - iface._scene._get_runtime_state_async = _frontend_round_trip - iface._server_manager = MagicMock() - iface._server_manager.running = True - - asyncio.run(iface.save_state(str(tmp_path))) - - with open(os.path.join(str(tmp_path), "visor.json"), "r") as fh: - written = json.load(fh) + persisted = iface._scene.get_state() - # write_state dumps by_alias, so the camera's own fields are aliased. - camera = written["scene"]["camera"] - assert camera["position"] == RECORD_CAMERA_POSITION - assert camera["clippingRange"] == RECORD_CAMERA_CLIPPING_RANGE - assert camera["position"] != REPLY_CAMERA_POSITION - assert camera["clippingRange"] != REPLY_CAMERA_CLIPPING_RANGE + assert persisted.scene.camera.position == RECORD_CAMERA_POSITION + assert persisted.scene.camera.clipping_range == RECORD_CAMERA_CLIPPING_RANGE diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index e67bf0aa..7001f26e 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -107,7 +107,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) @@ -119,7 +118,6 @@ def app_without_api(mock_server): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, ) diff --git a/tests/unit/app/test_local_app_set_projection.py b/tests/unit/app/test_local_app_set_projection.py index 77218b3f..60df18b6 100644 --- a/tests/unit/app/test_local_app_set_projection.py +++ b/tests/unit/app/test_local_app_set_projection.py @@ -81,7 +81,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) @@ -93,7 +92,6 @@ def app_without_api(mock_server): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, ) diff --git a/tests/unit/app/test_local_app_sync_camera.py b/tests/unit/app/test_local_app_sync_camera.py index 9d06ac67..f5ca8eb2 100644 --- a/tests/unit/app/test_local_app_sync_camera.py +++ b/tests/unit/app/test_local_app_sync_camera.py @@ -100,7 +100,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) @@ -112,7 +111,6 @@ def app_without_api(mock_server): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, ) diff --git a/tests/unit/app/test_local_app_sync_cross_section_plane.py b/tests/unit/app/test_local_app_sync_cross_section_plane.py index 9f9564ab..b977026b 100644 --- a/tests/unit/app/test_local_app_sync_cross_section_plane.py +++ b/tests/unit/app/test_local_app_sync_cross_section_plane.py @@ -77,7 +77,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) diff --git a/tests/unit/app/test_local_app_ui_panel_triggers.py b/tests/unit/app/test_local_app_ui_panel_triggers.py index 501d6b1a..1e5a800f 100644 --- a/tests/unit/app/test_local_app_ui_panel_triggers.py +++ b/tests/unit/app/test_local_app_ui_panel_triggers.py @@ -82,7 +82,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) diff --git a/tests/unit/app/test_local_app_widget_triggers.py b/tests/unit/app/test_local_app_widget_triggers.py index 9dec8cfe..b7a89929 100644 --- a/tests/unit/app/test_local_app_widget_triggers.py +++ b/tests/unit/app/test_local_app_widget_triggers.py @@ -77,7 +77,6 @@ def app(mock_server, api): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, scene_mutation_api=api, ) @@ -89,7 +88,6 @@ def app_without_api(mock_server): return LocalApp( server=mock_server, get_scene_details_json=MagicMock(), - handle_save_state_response=MagicMock(), standalone=True, ) diff --git a/tests/unit/app/test_visor_trame_app.py b/tests/unit/app/test_visor_trame_app.py index 6eab74ef..a8ce033f 100644 --- a/tests/unit/app/test_visor_trame_app.py +++ b/tests/unit/app/test_visor_trame_app.py @@ -41,18 +41,11 @@ def mock_get_scene_details_json(): return MagicMock(return_value={"scene": "details"}) -@pytest.fixture -def mock_handle_save_state_response(): - """Provide a mock save-state callback.""" - return MagicMock(return_value={"ok": True}) - - @patch("ansys.visor.viewer.app.trame.local_app.settings") def test_init_sets_attributes( mock_settings, mock_server, mock_get_scene_details_json, - mock_handle_save_state_response, mock_logger, ): """Verify that initialization stores the provided attributes.""" @@ -60,7 +53,6 @@ def test_init_sets_attributes( app = LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, trame_logger=mock_logger, ) @@ -70,26 +62,24 @@ def test_init_sets_attributes( def test_register_lifecycle_hooks_adds_handlers( - mock_server, mock_get_scene_details_json, mock_handle_save_state_response + mock_server, mock_get_scene_details_json ): """Verify that lifecycle event handlers are registered.""" LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, ) assert mock_server.controller.add_call_count >= 5 def test_set_headers_sets_http_headers( - mock_server, mock_get_scene_details_json, mock_handle_save_state_response + mock_server, mock_get_scene_details_json ): """Verify that the required HTTP headers are configured.""" LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, ) mock_server.http_headers.set_header.assert_any_call( @@ -105,27 +95,24 @@ def test_set_www_path_sets_www( mock_settings, mock_server, mock_get_scene_details_json, - mock_handle_save_state_response, ): """Verify that the client bundle path is assigned to the server.""" mock_settings.get_client_bundle.return_value = "/mock/path" LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, ) assert mock_server._www == "/mock/path" def test_get_visor_scene_details_json_returns_scene_details( - mock_server, mock_get_scene_details_json, mock_handle_save_state_response + mock_server, mock_get_scene_details_json ): """Verify that scene details are returned from the callback.""" app = LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, ) result = app.get_visor_scene_details_json() @@ -137,7 +124,6 @@ def test_trame_logger_is_set_when_provided( mock_settings, mock_server, mock_get_scene_details_json, - mock_handle_save_state_response, mock_logger, ): """Verify that the provided logger is stored.""" @@ -145,7 +131,6 @@ def test_trame_logger_is_set_when_provided( app = LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, trame_logger=mock_logger, ) @@ -157,14 +142,12 @@ def test_trame_logger_is_none_when_not_provided( mock_settings, mock_server, mock_get_scene_details_json, - mock_handle_save_state_response, ): """Verify that the logger is None when not provided.""" mock_settings.get_client_bundle.return_value = "/mock/path" app = LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, ) assert app._LocalApp__trame_logger is None @@ -175,7 +158,6 @@ def test_lifecycle_logging_calls_debug( mock_settings, mock_server, mock_get_scene_details_json, - mock_handle_save_state_response, mock_logger, ): """Verify that lifecycle events generate debug log messages.""" @@ -183,7 +165,6 @@ def test_lifecycle_logging_calls_debug( LocalApp( server=mock_server, get_scene_details_json=mock_get_scene_details_json, - handle_save_state_response=mock_handle_save_state_response, standalone=True, trame_logger=mock_logger, ) diff --git a/tests/unit/app/test_visor_vtk_local.py b/tests/unit/app/test_visor_vtk_local.py index 5e39298c..7fac3541 100644 --- a/tests/unit/app/test_visor_vtk_local.py +++ b/tests/unit/app/test_visor_vtk_local.py @@ -179,11 +179,11 @@ def test_save_state_writes_json(tmp_path, iface): import json from unittest.mock import MagicMock - # Arrange: fake state object returned by the (frontend-driven) get_state call + # Arrange: fake state object returned by the scene's get_state call state = MagicMock() state.model_dump_json.return_value = json.dumps({"foo": "bar"}) - async def _get_state(*args, **kwargs): + def _get_state(*args, **kwargs): return state iface._scene.get_state = _get_state @@ -219,7 +219,7 @@ def test_save_state_writes_dataset_snapshots(tmp_path, iface): state = MagicMock() state.scene.dataset_states = {} - async def _get_state(*args, **kwargs): + def _get_state(*args, **kwargs): return state iface._scene.get_state = _get_state @@ -257,7 +257,7 @@ def test_save_state_skips_clean_datasets(tmp_path, iface): state = MagicMock() state.scene.dataset_states = {} - async def _get_state(*args, **kwargs): + def _get_state(*args, **kwargs): return state iface._scene.get_state = _get_state @@ -589,26 +589,23 @@ def test_local_app_still_receives_the_pre_existing_arguments(local_app_call): """The boundary is extended, not broken: the earlier arguments survive.""" call, scene, instance = local_app_call - server, get_scene_details_json, handle_save_state_response, standalone = call.args + server, get_scene_details_json, standalone = call.args assert server is instance.server assert callable(get_scene_details_json) - assert callable(handle_save_state_response) assert standalone is True assert callable(call.kwargs["pick_geometry"]) assert "trame_logger" in call.kwargs def test_the_pre_existing_lambdas_still_delegate_to_the_scene(local_app_call): - """The three original callables are untouched and still reach the scene.""" + """The two original callables are untouched and still reach the scene.""" call, scene, _instance = local_app_call - _server, get_scene_details_json, handle_save_state_response, _standalone = call.args + _server, get_scene_details_json, _standalone = call.args get_scene_details_json() - handle_save_state_response(1, {"ok": True}) call.kwargs["pick_geometry"](2, 3, "vertex", 0.0, 1.0, 2.0) scene.get_scene_details_json.assert_called_once_with() - scene.handle_save_state_response.assert_called_once_with(1, {"ok": True}) scene.pick_geometry.assert_called_once_with(2, 3, "vertex", 0.0, 1.0, 2.0) diff --git a/tests/unit/models/test_visor_save_state_request.py b/tests/unit/models/test_visor_save_state_request.py deleted file mode 100644 index 2d11b171..00000000 --- a/tests/unit/models/test_visor_save_state_request.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from pydantic import ValidationError - -from ansys.visor.viewer.models.runtime.requests.visor_save_state_request import ( - VisorSaveStateRequest, -) - - -def test_create_valid_request(): - """Valid request should be created with request_id.""" - - req = VisorSaveStateRequest(request_id=123) - - assert req.request_id == 123 - - -def test_request_id_is_required(): - """Missing request_id should raise ValidationError.""" - - with pytest.raises(ValidationError): - VisorSaveStateRequest() - - -def test_request_id_must_be_int(): - """Non-integer request_id should raise ValidationError.""" - - with pytest.raises(ValidationError): - VisorSaveStateRequest(request_id="not-an-int") - - -def test_model_dump_contains_request_id(): - """model_dump(by_alias=True) should include the camelCase requestId wire field.""" - - req = VisorSaveStateRequest(request_id=5) - data = req.model_dump(by_alias=True) - - assert data == {"requestId": 5} - - -def test_arbitrary_types_allowed_behavior(): - """Model should allow arbitrary types without failure.""" - - class Dummy: - pass - - # Nothing in the schema uses arbitrary types directly, - # but Config enables it — this ensures it doesn't break. - req = VisorSaveStateRequest(request_id=1) - - assert isinstance(req, VisorSaveStateRequest) diff --git a/tests/unit/models/test_visor_save_state_response.py b/tests/unit/models/test_visor_save_state_response.py deleted file mode 100644 index b3742119..00000000 --- a/tests/unit/models/test_visor_save_state_response.py +++ /dev/null @@ -1,143 +0,0 @@ -import json -from unittest.mock import MagicMock - -import pytest -from pydantic import ValidationError - -from ansys.visor.viewer.models.runtime.requests.visor_save_state_response import ( - VisorSaveStateResponse, -) -from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState - -# ------------------------------------------------------------------ -# Helpers -# ------------------------------------------------------------------ - -def make_runtime_state(): - """Create a RuntimeAppState-compatible mock.""" - return MagicMock(spec=RuntimeAppState) - - -# ------------------------------------------------------------------ -# Tests -# ------------------------------------------------------------------ - -def test_create_response_from_dict(monkeypatch): - """Dict app_state should be converted via model_validate.""" - - monkeypatch.setattr( - RuntimeAppState, - "model_validate", - lambda data: make_runtime_state(), - ) - - resp = VisorSaveStateResponse(request_id=2, app_state={"x": 10}) - - assert isinstance(resp.app_state, RuntimeAppState) - - -def test_create_response_from_json_string(monkeypatch): - """JSON string app_state should be parsed and validated.""" - - monkeypatch.setattr( - RuntimeAppState, - "model_validate", - lambda data: make_runtime_state(), - ) - - data = json.dumps({"foo": "bar"}) - - resp = VisorSaveStateResponse(request_id=3, app_state=data) - - assert isinstance(resp.app_state, RuntimeAppState) - - -def test_create_valid_response_with_model_passthrough(): - """Already-constructed RuntimeAppState should pass through unchanged.""" - - existing = make_runtime_state() - - resp = VisorSaveStateResponse(request_id=1, app_state=existing) - - assert resp.app_state is existing - - -def test_invalid_request_id_raises(): - """Non-integer request_id should raise ValidationError.""" - - with pytest.raises(ValidationError): - VisorSaveStateResponse(request_id="bad", app_state={}) - - -def test_missing_app_state_raises(): - """Missing app_state should raise ValidationError.""" - - with pytest.raises(ValidationError): - VisorSaveStateResponse(request_id=1) - - -def test_invalid_json_string_raises(monkeypatch): - """Invalid JSON string should raise during parsing.""" - - monkeypatch.setattr( - RuntimeAppState, - "model_validate", - lambda data: make_runtime_state(), - ) - - with pytest.raises(Exception): - VisorSaveStateResponse(request_id=1, app_state="not-json") - - -def test_invalid_type_for_app_state_raises(): - """Unexpected app_state type should fail validation.""" - - with pytest.raises(ValidationError): - VisorSaveStateResponse(request_id=1, app_state=123) - - -def test_model_dump_contains_fields(monkeypatch): - """model_dump(by_alias=True) should include the expected camelCase wire fields.""" - - dummy = make_runtime_state() - - monkeypatch.setattr( - RuntimeAppState, - "model_validate", - lambda data: dummy, - ) - - resp = VisorSaveStateResponse(request_id=5, app_state={"k": "v"}) - data = resp.model_dump(by_alias=True) - - assert data["requestId"] == 5 - assert "appState" in data - - -def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): - """A browser ``variableStates`` entry in the record's shape reaches the model unaltered.""" - payload = { - "requestId": 1, - "appState": { - "scene": { - "variableStates": { - "POINT::pressure::1": { - "id": "POINT::pressure::1", - "arrayName": "pressure", - "type": "POINT", - "numComponents": 1, - "partIds": [1], - "defaultMagnitudeRange": [0.0, 1.0], - "defaultRanges": [[0.0, 1.0]], - "magnitudeRange": [0.0, 1.0], - "ranges": [[0.0, 1.0]], - } - } - } - }, - } - - resp = VisorSaveStateResponse.model_validate(payload) - - assert resp.app_state.scene.variable_states["POINT::pressure::1"].array_name == "pressure" - diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index e49688bc..620e362e 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -15,7 +15,6 @@ Behavioural coverage of the remaining shared logic lives in test_local_scene.py, exercised through the concrete VisorLocalScene subclass. """ -import asyncio import json import threading from unittest.mock import MagicMock, patch @@ -63,9 +62,8 @@ def test_cannot_instantiate_directly(): def test_abstract_methods(): - """VisorSceneBase declares exactly the two expected abstract hooks.""" + """VisorSceneBase declares exactly the one expected abstract hook.""" assert VisorSceneBase.__abstractmethods__ == { - "_get_runtime_state_async", "_push_runtime_state", } @@ -75,10 +73,7 @@ def test_abstract_methods(): # =========================================================================== class _ConcreteScene(VisorSceneBase): - """Smallest concrete VisorSceneBase: both abstract hooks are inert.""" - - async def _get_runtime_state_async(self, timeout: float): - return MagicMock(name="runtime_app_state") + """Smallest concrete VisorSceneBase: the abstract hook is inert.""" def _push_runtime_state(self, runtime_app_state) -> None: return None @@ -1061,38 +1056,14 @@ def _runtime_state(part_states, dataset_id=1): ) -def _frontend_state(): - """What the browser returns: per-part values for a dataset id of its own. - - Distinguishable on both axes — a dataset id the registry does not have and - an opacity the registry never held — so a test can tell registry-sourced - output from frontend-sourced output. - """ - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={ - FRONTEND_DATASET_ID: RuntimeDatasetState( - id=FRONTEND_DATASET_ID, - part_states={NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.99)}, - ) - }, - variable_states={}, - ) - - -def _capture_persist_input(scene, frontend_state): - """Wire get_state to *frontend_state* and capture what the mapper receives.""" +def _capture_persist_input(scene): + """Capture the runtime state get_state hands to the persist mapper.""" captured = {} - async def _get_runtime_state_async(timeout): - return frontend_state - def _runtime_to_persisted(runtime_state): captured["runtime_state"] = runtime_state return MagicMock(name="persisted") - scene._get_runtime_state_async = _get_runtime_state_async scene._state_mapper = MagicMock(name="state_mapper") scene._state_mapper.runtime_to_persisted.side_effect = _runtime_to_persisted return captured @@ -1140,40 +1111,24 @@ def second_pipeline(renderer, array_dataset): def test_get_state_sources_dataset_states_from_the_registry(scene, registry): """The persist mapper is handed the registry's per-part state.""" registry.set_part_opacity(NODE_ID, 0.25) - captured = _capture_persist_input(scene, _frontend_state()) + captured = _capture_persist_input(scene) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() mapped = captured["runtime_state"].scene.dataset_states assert list(mapped) == [1] assert mapped[1].part_states[NODE_ID].opacity == 0.25 -def test_get_state_discards_the_frontend_dataset_states(scene, registry): - """The browser's per-part state does not survive into the persisted state.""" - registry.set_part_opacity(NODE_ID, 0.25) - captured = _capture_persist_input(scene, _frontend_state()) - - asyncio.run(scene.get_state(timeout=1.0)) - - mapped = captured["runtime_state"].scene.dataset_states - assert FRONTEND_DATASET_ID not in mapped - assert all( - part.opacity != 0.99 - for dataset_state in mapped.values() - for part in dataset_state.part_states.values() - ) - - def test_get_state_reads_the_registry_under_the_lock(scene): """The lock is *held* at the moment the registry is read, not merely taken.""" scene._vtk_lock = _LockSpy() spy_registry = _DepthRecordingRegistry(scene) spy_registry.datasets = {1: _make_part_dataset(1, [NODE_ID])} scene._dataset_registry = spy_registry - _capture_persist_input(scene, _frontend_state()) + _capture_persist_input(scene) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() assert spy_registry.depth_at_read >= 1 assert scene._vtk_lock.depth == 0 @@ -1184,9 +1139,9 @@ def test_get_state_snapshots_the_registry_rather_than_referencing_it(scene, regi """A write landing after the read does not change what was captured.""" registry.set_part_opacity(NODE_ID, 0.25) live_state = registry.datasets[1].state - captured = _capture_persist_input(scene, _frontend_state()) + captured = _capture_persist_input(scene) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() registry.set_part_opacity(NODE_ID, 0.75) snapshot = captured["runtime_state"].scene.dataset_states[1] @@ -1198,17 +1153,16 @@ def test_get_state_snapshots_the_registry_rather_than_referencing_it(scene, regi # --------------------------------------------------------------------------- # Save path — the camera # -# The save takes the camera from the renderer's record. Three things could +# The save takes the camera from the renderer's record. Two things could # supply one and they are told apart here by value, not by call recording: # # * the record -> RECORD_* (what the save must write) -# * the browser's reply -> REPLY_* (what the save wrote before) # * the pipeline camera -> PIPELINE_* (the record's projection, which # re-imports VTK's own drift) # # The record and the pipeline agree in the running application except in # clipping_range, and only after a ResetCamera, so reading the pipeline is a -# silent failure there. The three literal sets below differ in every field so +# silent failure there. The two literal sets below differ in every field so # that it is not silent here. # # These tests run the REAL state mapper -- not the _capture_persist_input @@ -1229,9 +1183,6 @@ def test_get_state_snapshots_the_registry_rather_than_referencing_it(scene, regi RECORD_VIEW_ANGLE = 31.0 RECORD_PARALLEL_SCALE = 19.0 -REPLY_POSITION = [21.0, 22.0, 23.0] -REPLY_CLIPPING_RANGE = [27.0, 28.0] - PIPELINE_POSITION = [31.0, 32.0, 33.0] PIPELINE_CLIPPING_RANGE = [37.0, 38.0] @@ -1249,19 +1200,6 @@ def _record_camera() -> VisorCameraState: ) -def _reply_camera() -> VisorCameraState: - """What the browser answers getState with. Never the right answer.""" - return VisorCameraState( - position=REPLY_POSITION, - focal_point=[24.0, 25.0, 26.0], - view_up=[1.0, 0.0, 0.0], - clipping_range=REPLY_CLIPPING_RANGE, - parallel_projection=False, - view_angle=32.0, - parallel_scale=29.0, - ) - - def _pipeline_camera() -> VisorCameraState: """What a read of the pipeline vtkCamera would return.""" return VisorCameraState( @@ -1322,38 +1260,27 @@ def _read_pipeline_camera(self): return self._pipeline_camera -def _save_scene(scene, record, reply_camera): - """Wire *scene* for a save whose record is *record* and whose browser - reply carries *reply_camera*. Returns the renderer double.""" +def _save_scene(scene, record): + """Wire *scene* for a save whose camera record is *record*. + Returns the renderer double.""" double = _CameraRecordRenderer(record, _pipeline_camera(), scene=scene) scene._renderer = double scene._dataset_registry = VisorDatasetRegistry() - - async def _get_runtime_state_async(timeout): - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - camera=reply_camera, - variable_states={}, - ) - - scene._get_runtime_state_async = _get_runtime_state_async return double def test_get_state_takes_the_camera_from_the_record(scene): """The saved camera is the record's, field for field. - This is the assertion that pins the change. Reverted, the camera on the - returned state is the browser's reply and every field below differs. + This is the assertion that pins the camera source. Drop the assignment and + the returned state carries no camera at all. Asserted on what get_state RETURNS -- the object that reaches the writer -- not on the runtime state it was built from. """ - _save_scene(scene, _record_camera(), _reply_camera()) + _save_scene(scene, _record_camera()) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() camera = persisted.scene.camera assert camera.position == RECORD_POSITION @@ -1365,21 +1292,6 @@ def test_get_state_takes_the_camera_from_the_record(scene): assert camera.parallel_scale == RECORD_PARALLEL_SCALE -def test_get_state_discards_the_camera_the_browser_returned(scene): - """The browser's camera does not survive into the persisted state. - - Its own test rather than an extra assertion above: "wrote the record" and - "did not write the reply" are the same only while the round trip still - carries a camera at all, and the round trip is not being removed. - """ - _save_scene(scene, _record_camera(), _reply_camera()) - - persisted = asyncio.run(scene.get_state(timeout=1.0)) - - assert persisted.scene.camera.position != REPLY_POSITION - assert persisted.scene.camera.clipping_range != REPLY_CLIPPING_RANGE - - def test_get_state_does_not_read_the_pipeline_camera(scene): """The record is read; the pipeline is not. @@ -1387,9 +1299,9 @@ def test_get_state_does_not_read_the_pipeline_camera(scene): running application the two agree except in clipping_range, and only after a ResetCamera, so no manual check discriminates them reliably. """ - double = _save_scene(scene, _record_camera(), _reply_camera()) + double = _save_scene(scene, _record_camera()) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert double.record_reads == 1 assert double.pipeline_reads == 0 @@ -1398,17 +1310,17 @@ def test_get_state_does_not_read_the_pipeline_camera(scene): def test_get_state_writes_no_camera_when_the_record_is_empty(scene): - """Negative twin: an empty record writes no camera, reply notwithstanding. + """An empty record writes no camera. The record is None only for a scene that never held a dataset, since the - first one resets the camera and that reset writes the record. The reply - carries a perfectly valid camera, so this is the only test in the module - that a guarded assignment -- one that skipped the write when the record - was None -- would fail. + first one resets the camera and that reset writes the record. get_state + starts from an empty runtime state whose camera is already None, so this + test cannot tell an unconditional assignment from a guarded one; it pins + only that nothing else supplies a camera. """ - _save_scene(scene, None, _reply_camera()) + _save_scene(scene, None) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.camera is None @@ -1419,13 +1331,12 @@ def test_get_state_reads_the_camera_under_the_lock(scene): The record is a single attribute holding a whole object reference, but the read sits in the same critical section as the registry snapshot and is asserted the same way, on the precedent of - test_get_state_reads_the_registry_under_the_lock -- which also covers the - await, since the lock is taken after it and never held across it. + test_get_state_reads_the_registry_under_the_lock. """ scene._vtk_lock = _LockSpy() - double = _save_scene(scene, _record_camera(), _reply_camera()) + double = _save_scene(scene, _record_camera()) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() assert double.depth_at_read >= 1 assert scene._vtk_lock.depth == 0 @@ -2238,8 +2149,8 @@ def test_apply_state_component_without_variable_id_does_not_clear( # # Three toggles the server now holds: cross-section, edges, bounding box. # Each has a coordinator method that writes the store and applies to the -# renderer under ``_vtk_lock``; ``get_state`` reads the store rather than the -# browser's reply; ``apply_state`` restores it; ``get_scene_details`` delivers +# renderer under ``_vtk_lock``; ``get_state`` reads the store; ``apply_state`` +# restores it; ``get_scene_details`` delivers # it to a rebuilt or reconnecting client. # # Every expected value below is a hand-written literal. ``True`` is used @@ -2251,11 +2162,6 @@ def test_apply_state_component_without_variable_id_does_not_clear( TOGGLE_ON = True TOGGLE_OFF = False -# What a browser that was asked would answer. Deliberately the opposite of -# what the store holds in the derivation test, so that "read the store" and -# "read the reply" cannot both pass. -REPLY_TOGGLE = False - DELIVERED_PARALLEL_PROJECTION = True @@ -2343,27 +2249,14 @@ def _delivered_camera() -> VisorCameraState: ) -def _toggle_save_scene(scene, reply_toggle): - """Wire *scene* for a save whose browser reply carries *reply_toggle*. +def _toggle_save_scene(scene): + """Wire *scene* for a save. The registry is emptied so the mapper's per-dataset loop contributes nothing; what is under test is the scene block of the persisted state. """ scene._dataset_registry = VisorDatasetRegistry() - async def _get_runtime_state_async(timeout): - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - cross_section_enabled=reply_toggle, - edges_enabled=reply_toggle, - bounding_box_enabled=reply_toggle, - variable_states={}, - ) - - scene._get_runtime_state_async = _get_runtime_state_async - # --------------------------------------------------------------------------- # The store's initial value @@ -2468,17 +2361,16 @@ def test_set_edges_visible_holds_the_lock_at_the_renderer_call(scene): # --------------------------------------------------------------------------- -# get_state -- the toggles come from the store, not from the reply +# get_state -- the toggles come from the store # --------------------------------------------------------------------------- def test_get_state_takes_the_toggles_from_the_store(scene): - """The saved toggles are the server's, with the browser saying otherwise. + """The saved toggles are the server's. - This is the assertion that pins the change. The reply carries the - hand-written literal ``False`` for all three while the store holds ``True`` - for all three; revert the three get_state assignments and every assertion - below reports ``False``. That single difference is what separates - "server-authoritative" from "round-trips the client's answer". + This is the assertion that pins the store as the source. The store holds + ``True`` for all three against a default of ``False``; revert the three + get_state assignments and every assertion below fails, because the empty + runtime state carries ``None`` for all three. Asserted on what get_state RETURNS -- the object that reaches the writer -- not on the runtime state it was built from. @@ -2486,33 +2378,15 @@ def test_get_state_takes_the_toggles_from_the_store(scene): scene.set_cross_section_visibility(TOGGLE_ON) scene.set_edges_visible(TOGGLE_ON) scene.set_bounding_box_visibility(TOGGLE_ON) - _toggle_save_scene(scene, REPLY_TOGGLE) + _toggle_save_scene(scene) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.cross_section_enabled is True assert persisted.scene.edges_enabled is True assert persisted.scene.bounding_box_enabled is True -def test_get_state_discards_the_toggles_the_browser_returned(scene): - """The browser's toggles do not survive into the persisted state. - - The mirror of the test above and its own test for the same reason the - camera pair is split: "wrote the store" and "did not write the reply" are - the same only while the round trip still carries toggles at all, and the - round trip is not being removed. Here the store is left at its default - ``False`` and the reply carries ``True``. - """ - _toggle_save_scene(scene, True) - - persisted = asyncio.run(scene.get_state(timeout=1.0)) - - assert persisted.scene.cross_section_enabled is False - assert persisted.scene.edges_enabled is False - assert persisted.scene.bounding_box_enabled is False - - # --------------------------------------------------------------------------- # apply_state -- the load path writes the store and the renderer # --------------------------------------------------------------------------- @@ -2719,33 +2593,15 @@ def test_set_part_visibility_unknown_node_id_does_not_fan_out(scene): PROJECTION_ON = True PROJECTION_OFF = False -# What a browser that was asked would answer for the persisted toggle. -# Deliberately the opposite of the record's parallel_projection, so "derived -# from the record" and "passed through from the reply" cannot both pass. -REPLY_ORTHOGRAPHIC = False - - -def _projection_save_scene(scene, record, reply_orthographic): - """Wire *scene* for a save: renderer record *record*, reply *orthographic*. +def _projection_save_scene(scene, record): + """Wire *scene* for a save: renderer record *record*. - The same shape as ``_save_scene`` above, but the reply carries the - persisted projection toggle rather than a camera, because that is the - field whose source is under test. + The same shape as ``_save_scene`` above; the persisted projection toggle + is the field whose source is under test. """ double = _CameraRecordRenderer(record, _pipeline_camera(), scene=scene) scene._renderer = double scene._dataset_registry = VisorDatasetRegistry() - - async def _get_runtime_state_async(timeout): - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - orthographic_enabled=reply_orthographic, - variable_states={}, - ) - - scene._get_runtime_state_async = _get_runtime_state_async return double @@ -2876,12 +2732,11 @@ def test_set_projection_does_not_notify_the_client(scene): # --------------------------------------------------------------------------- def test_get_state_derives_orthographic_enabled_from_the_camera_record(scene): - """The saved projection is the record's, with the browser saying otherwise. + """The saved projection is the record's. This is the assertion that closes AC-5. The record carries the - hand-written literal ``True`` while the reply carries the hand-written - literal ``False``; revert the derivation and the assertion reports - ``False``, which is the reply's answer passed through. + hand-written literal ``True``; revert the derivation and the assertion + fails, because the empty runtime state carries ``None``. ``record_reads == 1`` is asserted here too: the record is bound once and read once, so the camera and the projection are answers to a single @@ -2890,9 +2745,9 @@ def test_get_state_derives_orthographic_enabled_from_the_camera_record(scene): Asserted on what get_state RETURNS -- the object that reaches the writer -- not on the runtime state it was built from. """ - double = _projection_save_scene(scene, _record_camera(), REPLY_ORTHOGRAPHIC) + double = _projection_save_scene(scene, _record_camera()) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.orthographic_enabled is True assert persisted.scene.camera.parallel_projection is True @@ -2903,13 +2758,14 @@ def test_get_state_orthographic_enabled_is_none_when_the_record_is_empty(scene): """An empty record emits ``None``, not a fabricated ``False``. ``None`` says "nothing was ever written"; ``False`` would assert - perspective over a client that may be parallel. The reply carries - ``True`` here, so a derivation that dropped its guard and fell back to the - reply would be visible rather than coincide. + perspective over a client that may be parallel. get_state starts from an + empty runtime state whose projection is already ``None``, so this test + cannot tell the derivation from a dropped one; it pins only that nothing + fabricates a value. """ - _projection_save_scene(scene, None, True) + _projection_save_scene(scene, None) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.orthographic_enabled is None assert persisted.scene.camera is None @@ -2938,12 +2794,6 @@ def test_get_state_orthographic_enabled_is_none_when_the_record_is_empty(scene): RECORD_CROSS_SECTION_ORIGIN = [51.0, 52.0, 53.0] RECORD_CROSS_SECTION_NORMAL = [0.0, 1.0, 0.0] -# What the browser answers getState with. Never the right answer, and -# different in every component, so "read the record" and "read the reply" -# cannot both pass. -REPLY_CROSS_SECTION_ORIGIN = [61.0, 62.0, 63.0] -REPLY_CROSS_SECTION_NORMAL = [1.0, 0.0, 0.0] - # What a settled drag reports, and what a save file carries on load. GESTURE_CROSS_SECTION_ORIGIN = [71.0, 72.0, 73.0] GESTURE_CROSS_SECTION_NORMAL = [0.0, 0.0, 1.0] @@ -2960,38 +2810,18 @@ def _record_plane() -> VisorCrossSectionState: ) -def _reply_plane() -> VisorCrossSectionState: - """What the browser answers getState with. Never the right answer.""" - return VisorCrossSectionState( - origin=REPLY_CROSS_SECTION_ORIGIN, - normal=REPLY_CROSS_SECTION_NORMAL, - ) - - -def _plane_save_scene(scene, record_plane, reply_plane): - """Wire *scene* for a save: renderer record *record_plane*, reply *reply_plane*. +def _plane_save_scene(scene, record_plane): + """Wire *scene* for a save: renderer plane record *record_plane*. - The same shape as ``_save_scene`` above, but the reply carries a plane - rather than a camera, because that is the field whose source is under - test. The registry is emptied so the mapper's per-dataset loop - contributes nothing. + The same shape as ``_save_scene`` above; the plane is the field whose + source is under test. The registry is emptied so the mapper's + per-dataset loop contributes nothing. """ double = _CameraRecordRenderer( None, _pipeline_camera(), scene=scene, cross_section=record_plane ) scene._renderer = double scene._dataset_registry = VisorDatasetRegistry() - - async def _get_runtime_state_async(timeout): - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="m", - dataset_states={}, - cross_section=reply_plane, - variable_states={}, - ) - - scene._get_runtime_state_async = _get_runtime_state_async return double @@ -3076,45 +2906,40 @@ def _sync(origin, normal): # --------------------------------------------------------------------------- -# get_state -- the plane comes from the record, not from the reply +# get_state -- the plane comes from the record # --------------------------------------------------------------------------- def test_get_state_takes_the_cross_section_from_the_record(scene): - """The saved plane is the record's, with the browser saying otherwise. + """The saved plane is the record's. - The record and the reply differ in every component, so "server - authoritative" and "round-trips the client's answer" cannot both pass. - Revert the assignment and both assertions below report the reply's - numbers. + Revert the assignment and both assertions below fail, because the empty + runtime state carries no plane. Asserted on what get_state RETURNS -- the object that reaches the writer -- not on the runtime state it was built from. An assignment placed after ``runtime_to_persisted`` passes every assertion made against the runtime object and still writes the wrong file. """ - _plane_save_scene(scene, _record_plane(), _reply_plane()) + _plane_save_scene(scene, _record_plane()) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.cross_section.origin == RECORD_CROSS_SECTION_ORIGIN assert persisted.scene.cross_section.normal == RECORD_CROSS_SECTION_NORMAL def test_get_state_writes_no_cross_section_when_the_record_is_empty(scene): - """An empty record writes ``None`` through, reply notwithstanding. - - The assignment is unconditional, exactly as the camera's, and this is the - only test that a guarded one -- one that skipped the write when the record - was ``None`` -- would fail. The reply carries a perfectly valid plane, so - the guarded version would save the browser's answer and look correct - everywhere else. + """An empty record writes ``None`` through. ``None`` says "no plane was ever written". The guard for "absent says - nothing" belongs to the load path, not here. + nothing" belongs to the load path, not here. get_state starts from an + empty runtime state whose plane is already ``None``, so this test cannot + tell an unconditional assignment from a guarded one; it pins only that + nothing else supplies a plane. """ - _plane_save_scene(scene, None, _reply_plane()) + _plane_save_scene(scene, None) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.scene.cross_section is None @@ -3192,17 +3017,10 @@ def _sync(origin, normal): PANEL_COLLAPSED = True PANEL_TAB_INDEX = 1 -# What a browser that was asked would answer. Deliberately the opposite of -# what the store holds in the derivation test, so that "read the store" and -# "read the reply" cannot both pass. -REPLY_PANEL_COLLAPSED = False -REPLY_PANEL_TAB_INDEX = 0 - -# The theme pair. The server holds one value and the browser reports the -# other, so that "wrote dark_mode" and "passed the reply through" are -# distinguishable in a single assertion. +# The theme. The server holds the opposite of its constructor default, so +# "wrote dark_mode" and "wrote a default" are distinguishable in a single +# assertion. SERVER_DARK_MODE = True -REPLY_DARK_THEME = False class _PanelStoreLockSpy(_LockSpy): @@ -3229,25 +3047,8 @@ def __exit__(self, exc_type, exc, tb): return super().__exit__(exc_type, exc, tb) -def _reply_ui( - panel_top_left_panel_collapsed, - panel_top_right_panel_collapsed, - panel_top_right_legend_collapsed, - panel_top_right_tab_index, - dark_theme=REPLY_DARK_THEME, -): - """The ``ui`` block a browser round trip answers with.""" - return VisorUIState( - dark_theme=dark_theme, - panel_top_left_panel_collapsed=panel_top_left_panel_collapsed, - panel_top_right_panel_collapsed=panel_top_right_panel_collapsed, - panel_top_right_legend_collapsed=panel_top_right_legend_collapsed, - panel_top_right_tab_index=panel_top_right_tab_index, - ) - - -def _ui_save_scene(scene, reply_ui): - """Wire *scene* for a save whose browser reply carries *reply_ui*. +def _ui_save_scene(scene): + """Wire *scene* for a save. The same shape as ``_toggle_save_scene`` above; the registry is emptied so the mapper's per-dataset loop contributes nothing, and what is under test @@ -3255,16 +3056,6 @@ def _ui_save_scene(scene, reply_ui): """ scene._dataset_registry = VisorDatasetRegistry() - async def _get_runtime_state_async(timeout): - return RuntimeAppState.from_components( - ui=reply_ui, - unit="m", - dataset_states={}, - variable_states={}, - ) - - scene._get_runtime_state_async = _get_runtime_state_async - def _panel_runtime_state(ui): """A real RuntimeAppState carrying only the UI record named.""" @@ -3325,16 +3116,17 @@ def test_set_panel_top_right_tab_index_holds_the_lock_at_the_store_write(scene): # --------------------------------------------------------------------------- -# get_state -- the UI record comes from the server, not from the reply +# get_state -- the UI record comes from the server # --------------------------------------------------------------------------- def test_get_state_takes_the_ui_panel_state_from_the_store(scene): - """The saved panel state is the server's, with the browser saying otherwise. + """The saved panel state is the server's. - This is the assertion that pins the change. The store holds the - hand-written literals ``True, True, True, 1`` while the reply carries - ``False, False, False, 0``; delete the ``runtime_state.ui`` assignment in - get_state and every assertion below reports the reply's value instead. + This is the assertion that pins the store as the source. The store holds + the hand-written literals ``True, True, True, 1`` against initial values + of ``False, False, False, 0``; delete the ``runtime_state.ui`` assignment + in get_state and every assertion below fails, because the empty runtime + state's ``ui`` carries none of them. Asserted on what get_state RETURNS -- the object that reaches the writer -- not on the runtime state it was built from. @@ -3343,17 +3135,9 @@ def test_get_state_takes_the_ui_panel_state_from_the_store(scene): scene.set_panel_top_right_panel_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_legend_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_tab_index(PANEL_TAB_INDEX) - _ui_save_scene( - scene, - _reply_ui( - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_TAB_INDEX, - ), - ) + _ui_save_scene(scene) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.ui.panel_top_left_panel_collapsed is True assert persisted.ui.panel_top_right_panel_collapsed is True @@ -3361,61 +3145,19 @@ def test_get_state_takes_the_ui_panel_state_from_the_store(scene): assert persisted.ui.panel_top_right_tab_index == 1 -def test_get_state_discards_the_ui_panel_state_the_browser_returned(scene): - """The browser's panel state does not survive into the persisted state. - - The mirror of the test above and its own test for the same reason the - camera and toggle pairs are split: "wrote the store" and "did not write - the reply" are the same only while the round trip still carries a ``ui`` - block at all, and the round trip is not being removed. Here the store is - left at its initial values and the reply carries ``True, True, True, 1``. - - The four expected values are the hand-written literals ``False``, - ``False``, ``False`` and ``0``, not a read of the store: a store - initialised to ``None`` -- the silent failure the delivery decision - turns on -- fails this test on value. - """ - _ui_save_scene( - scene, - _reply_ui( - PANEL_COLLAPSED, - PANEL_COLLAPSED, - PANEL_COLLAPSED, - PANEL_TAB_INDEX, - ), - ) - - persisted = asyncio.run(scene.get_state(timeout=1.0)) - - assert persisted.ui.panel_top_left_panel_collapsed is False - assert persisted.ui.panel_top_right_panel_collapsed is False - assert persisted.ui.panel_top_right_legend_collapsed is False - assert persisted.ui.panel_top_right_tab_index == 0 - - def test_get_state_takes_dark_theme_from_the_server_not_the_browser(scene): - """The saved theme is ``dark_mode``, with the browser saying otherwise. + """The saved theme is ``dark_mode``. Its own test rather than a fifth assertion above, because it is a different source: the other four come from the panel store, this one from - the public ``dark_mode`` attribute, and no trigger writes it. The reply - carries the opposite literal, which is what a Dash host prop override - produces in the browser today; before this change that value reached the - file and, on the next load, the server's own ``dark_mode``. + the public ``dark_mode`` attribute, and no trigger writes it. The server + holds ``True`` against a constructor default of ``False``, so a theme + taken from anywhere but ``dark_mode`` fails on value. """ scene.dark_mode = SERVER_DARK_MODE - _ui_save_scene( - scene, - _reply_ui( - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_TAB_INDEX, - dark_theme=REPLY_DARK_THEME, - ), - ) + _ui_save_scene(scene) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() assert persisted.ui.dark_theme is True @@ -3470,8 +3212,8 @@ def test_apply_state_leaves_an_absent_ui_panel_field_alone(scene): # get_state -- the record is handed out as a copy # --------------------------------------------------------------------------- -# A later write, distinct from both the store's seeded values and the reply's, -# so a returned state that moved reports a value belonging to neither. +# A later write, distinct from the store's seeded values, so a returned state +# that moved reports a value the save never saw. LATER_PANEL_TAB_INDEX = 9 LATER_PANEL_COLLAPSED = False @@ -3499,17 +3241,9 @@ def test_get_state_hands_out_a_copy_of_the_ui_record(scene): scene.set_panel_top_right_panel_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_legend_collapsed(PANEL_COLLAPSED) scene.set_panel_top_right_tab_index(PANEL_TAB_INDEX) - _ui_save_scene( - scene, - _reply_ui( - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_COLLAPSED, - REPLY_PANEL_TAB_INDEX, - ), - ) + _ui_save_scene(scene) - persisted = asyncio.run(scene.get_state(timeout=1.0)) + persisted = scene.get_state() scene.set_panel_top_right_tab_index(LATER_PANEL_TAB_INDEX) scene.set_panel_top_left_panel_collapsed(LATER_PANEL_COLLAPSED) @@ -3923,35 +3657,22 @@ def test_set_state_push_carries_the_record(scene, registry): } -def _contradictory_reply(): - """The browser's reply: a different range for the same id, a stray id, and another unit.""" - return RuntimeAppState.from_components( - ui=VisorUIState(dark_theme=False), - unit="ft", - dataset_states={}, - variable_states={ - VARIABLE_ID: _pressure_record(magnitude_range=(900.0, 901.0), ranges=[(902.0, 903.0)]), - "POINT::stray::1": _pressure_record(id="POINT::stray::1", array_name="stray"), - }, - ) - - def test_get_state_takes_the_variable_states_from_the_server(scene, registry): - """#19: the save receives the server's record, whatever the browser replied.""" + """#19: the save receives the server's record.""" _seed_part_variables(registry, [_pressure_variable()]) scene._rebuild_variable_records_from_registry() - captured = _capture_persist_input(scene, _contradictory_reply()) + captured = _capture_persist_input(scene) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() assert captured["runtime_state"].scene.variable_states == {VARIABLE_ID: _pressure_record()} def test_get_state_takes_the_unit_from_the_registry(scene, registry): - """#20: the save receives the registry's unit, whatever the browser replied.""" + """#20: the save receives the registry's unit.""" registry.unit = "mm" - captured = _capture_persist_input(scene, _contradictory_reply()) + captured = _capture_persist_input(scene) - asyncio.run(scene.get_state(timeout=1.0)) + scene.get_state() assert captured["runtime_state"].scene.unit == "mm" diff --git a/tests/unit/vtk/scene/test_local_scene.py b/tests/unit/vtk/scene/test_local_scene.py index 3692caf8..859f12ab 100644 --- a/tests/unit/vtk/scene/test_local_scene.py +++ b/tests/unit/vtk/scene/test_local_scene.py @@ -746,11 +746,8 @@ def __init__(self): self._reset_called = False self._render_called = False - # Implement the two abstract hooks with no-op stubs so the class is - # instantiable. These stubs are never called by finalize_scene tests. - async def _get_runtime_state_async(self, timeout: float): - return None - + # Implement the abstract hook with a no-op stub so the class is + # instantiable. The stub is never called by finalize_scene tests. def _push_runtime_state(self, runtime_app_state) -> None: pass diff --git a/tests/unit/vtk/scene/test_visor_frontend_bridge.py b/tests/unit/vtk/scene/test_visor_frontend_bridge.py index fd5f8a79..a48321de 100644 --- a/tests/unit/vtk/scene/test_visor_frontend_bridge.py +++ b/tests/unit/vtk/scene/test_visor_frontend_bridge.py @@ -1,7 +1,3 @@ -import asyncio - -import pytest - from ansys.visor.viewer.vtk.scene.visor_frontend_bridge import VisorFrontendBridge # ------------------------------------------------------------------ @@ -53,262 +49,3 @@ def model_dump(self, by_alias=True): assert server.calls[0][0] == "visor-frontend-ref" assert server.calls[0][1] == "setState" assert server.calls[0][2] == {"id": 123} - - -# ------------------------------------------------------------------ -# request_state -# ------------------------------------------------------------------ - -@pytest.mark.asyncio -async def test_request_state_resolves(monkeypatch): - """request_state should await and return resolved response.""" - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.get_random_javascript_safe_id", - lambda: 10, - ) - - class FakeRequest: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - def model_dump(self, exclude_none=True, by_alias=True): - return {"requestId": self.request_id} - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateRequest", - FakeRequest, - ) - - # ✅ FIX: mock model_validate - class FakeResponse: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - lambda data: FakeResponse(data["requestId"]), - ) - - server = FakeServer() - bridge = VisorFrontendBridge(server, "visor-frontend-ref") - - async def resolve_later(): - await asyncio.sleep(0) - bridge.resolve_save_state_response(10, {"requestId": 10}) - - asyncio.create_task(resolve_later()) - - response = await bridge.request_state(timeout=1) - - assert response.request_id == 10 - assert len(server.calls) == 1 - assert bridge._ref_name == "visor-frontend-ref" - assert server.calls[0][0] == "visor-frontend-ref" - assert server.calls[0][1] == "getState" - - -@pytest.mark.asyncio -async def test_request_state_cleans_up(monkeypatch): - """Pending request should be removed after completion.""" - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.get_random_javascript_safe_id", - lambda: 1, - ) - - class FakeRequest: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - def model_dump(self, exclude_none=True, by_alias=True): - return {"requestId": self.request_id} - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateRequest", - FakeRequest, - ) - - # ✅ FIX: mock model_validate - class FakeResponse: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - lambda data: FakeResponse(data["requestId"]), - ) - - server = FakeServer() - bridge = VisorFrontendBridge(server, "visor-frontend-ref") - - async def resolve(): - await asyncio.sleep(0) - bridge.resolve_save_state_response(1, {"requestId": 1}) - - asyncio.create_task(resolve()) - await bridge.request_state(timeout=1) - - assert bridge._pending_requests == {} - assert bridge._pending_loop is None - - -# ------------------------------------------------------------------ -# resolve_save_state_response -# ------------------------------------------------------------------ - -def test_resolve_sets_result(monkeypatch): - """resolve_save_state_response should complete pending future.""" - - class FakeResponse: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - lambda data: FakeResponse(data["requestId"]), - ) - - bridge = VisorFrontendBridge(FakeServer(), "visor-frontend-ref") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - - bridge._pending_requests[5] = fut - bridge._pending_loop = None - - bridge.resolve_save_state_response(5, {"requestId": 5}) - - assert fut.done() - assert fut.result().request_id == 5 - - -def test_resolve_invalid_response_sets_exception(monkeypatch): - """Invalid response should set exception on future.""" - - from pydantic import ValidationError - - def raise_error(_): - raise ValidationError.from_exception_data("x", []) - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - raise_error, - ) - - bridge = VisorFrontendBridge(FakeServer(), "visor-frontend-ref") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - - bridge._pending_requests[1] = fut - bridge._pending_loop = None - - with pytest.raises(ValueError): - bridge.resolve_save_state_response(1, {}) - - assert fut.done() - assert isinstance(fut.exception(), ValidationError) - - -def test_resolve_unknown_request_logs_warning(monkeypatch): - """Unknown request_id should trigger warning.""" - - warnings = [] - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.logger.warning", - lambda msg: warnings.append(msg), - ) - - class FakeResponse: - def __init__(self, request_id): # noqa: N803 - self.request_id = request_id - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - lambda data: FakeResponse(data["requestId"]), - ) - - bridge = VisorFrontendBridge(FakeServer(), "visor-frontend-ref") - - bridge.resolve_save_state_response(999, {"requestId": 999}) - - assert len(warnings) == 1 - assert "no pending async waiter" in warnings[0] - - -# ------------------------------------------------------------------ -# threadsafe helpers -# ------------------------------------------------------------------ - -def test_threadsafe_set_result_no_loop(): - """_threadsafe_set_result should set result without loop.""" - - bridge = VisorFrontendBridge(None, "") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - - bridge._threadsafe_set_result(None, fut, "value") - - assert fut.done() - assert fut.result() == "value" - - -def test_threadsafe_set_exception_no_loop(): - """_threadsafe_set_exception should set exception without loop.""" - - bridge = VisorFrontendBridge(None, "") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - - exc = RuntimeError("error") - bridge._threadsafe_set_exception(None, fut, exc) - - assert fut.done() - assert fut.exception() is exc - - -def test_threadsafe_helpers_ignore_done_future(): - """Threadsafe helpers should not overwrite completed futures.""" - - bridge = VisorFrontendBridge(None, "") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - fut.set_result("done") - - bridge._threadsafe_set_result(None, fut, "new") - bridge._threadsafe_set_exception(None, fut, RuntimeError()) - - assert fut.result() == "done" - -def test_resolve_unexpected_exception_sets_exception_and_reraises(monkeypatch): - """Unexpected exception during validation should set future exception and re-raise.""" - - # Force a non-ValidationError exception - def raise_unexpected(_): - raise RuntimeError("boom") - - monkeypatch.setattr( - "ansys.visor.viewer.vtk.scene.visor_frontend_bridge.VisorSaveStateResponse.model_validate", - raise_unexpected, - ) - - bridge = VisorFrontendBridge(FakeServer(), "visor-frontend-ref") - - loop = asyncio.new_event_loop() - fut = loop.create_future() - - bridge._pending_requests[42] = fut - bridge._pending_loop = None - - with pytest.raises(RuntimeError): - bridge.resolve_save_state_response(42, {"requestId": 42}) - - # Future should be completed with the same exception - assert fut.done() - assert isinstance(fut.exception(), RuntimeError) - assert str(fut.exception()) == "boom" diff --git a/tests/unit/vtk/test_wire_format_identity.py b/tests/unit/vtk/test_wire_format_identity.py index 263b5035..6d853d2a 100644 --- a/tests/unit/vtk/test_wire_format_identity.py +++ b/tests/unit/vtk/test_wire_format_identity.py @@ -134,11 +134,8 @@ def get_camera_state(self): class _WireFormatScene(VisorSceneBase): """Minimal concrete ``VisorSceneBase`` for exercising the real - ``get_scene_details()`` path end to end. The two abstract hooks are - unused by that path and are never called by this test.""" - - async def _get_runtime_state_async(self, timeout: float): - raise NotImplementedError + ``get_scene_details()`` path end to end. The abstract hook is + unused by that path and is never called by this test.""" def _push_runtime_state(self, runtime_app_state) -> None: raise NotImplementedError From a9a86d908a7ef3bff25c778d8a0e12152692bf3f Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 8 Oct 2026 08:51:33 -0700 Subject: [PATCH 2/5] chore: remove getState callbacks from client --- src/ansys/visor/visor-client/src/App.tsx | 18 ------------------ .../visor-client/src/wasm/RemoteVtkScene.js | 17 ----------------- 2 files changed, 35 deletions(-) diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index 138cc86f..e820be88 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -163,7 +163,6 @@ function App() { setVisorFrontend(newFrontend); // rebuild/remount React UI } finally { wasmView.current.addServerUpdatedListener(onServerUpdateAsync); - wasmView.current.addGetStateListener(onGetState); wasmView.current.addSetStateListener(onSetStateAsync); } if (oldFrontend != null) { @@ -183,23 +182,6 @@ function App() { } } - async function onGetState(payload: any) { - if (wasmView.current == null) { - throw new Error(`wasmView should not be null here`); - } - const oldFrontend: VisorFrontend = (window as any).__visorState; - const appState = await oldFrontend.getAppStateAsync(); - const response = { - requestId: payload.requestId, - appState: appState.toDict(), - }; - await wasmView.current.trameTriggerAsync( - 'save_state_response', - payload.requestId, - response - ); - } - async function onSetStateAsync(payload: any) { if (wasmView.current == null) { throw new Error(`wasmView should not be null here`); diff --git a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js index 4006a6f2..6d548b6b 100644 --- a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js @@ -213,11 +213,6 @@ export default class RemoteVtkScene { trameInterface.addSingleCallback('startEventLoop', instance.#startEventLoop); trameInterface.addSingleCallback('stopEventLoop', instance.#stopEventLoop); trameInterface.addSingleCallback('update', instance.updateAsync); - trameInterface.addSingleCallback('getState', (payload) => { - for (const callback of instance.#getStateListeners.values()) { - callback(payload); - } - }); trameInterface.addSingleCallback('setState', (payload) => { for (const callback of instance.#setStateListeners.values()) { callback(payload); @@ -420,8 +415,6 @@ export default class RemoteVtkScene { /**@type{Mapvoid>}*/ #serverUpdatedListeners = new Map(); /**@type{Mapvoid>}*/ - #getStateListeners = new Map(); - /**@type{Mapvoid>}*/ #setStateListeners = new Map(); /** * @return {void} @@ -431,7 +424,6 @@ export default class RemoteVtkScene { remover(); } this.#serverUpdatedListeners.clear(); - this.#getStateListeners.clear(); this.#setStateListeners.clear(); }; /** @@ -443,15 +435,6 @@ export default class RemoteVtkScene { this.#serverUpdatedListeners.set(remover, handler); return remover; }; - /** - * @param {(payload:any)=>void} handler - * @return {()=>void} - */ - addGetStateListener = (handler) => { - const remover = () => this.#getStateListeners.delete(remover); - this.#getStateListeners.set(remover, handler); - return remover; - }; /** * @param {(payload:any)=>void} handler * @return {()=>void} From 6478dce239420c30ee7bf34392468fe68318adea Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 8 Oct 2026 09:55:52 -0700 Subject: [PATCH 3/5] chore: stop re-applying the old frontend's state after a rebuild --- src/ansys/visor/visor-client/src/App.tsx | 4 ---- .../src/jest-tests/VariableRecordProjection.test.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index e820be88..12f3bbde 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -165,10 +165,6 @@ function App() { wasmView.current.addServerUpdatedListener(onServerUpdateAsync); wasmView.current.addSetStateListener(onSetStateAsync); } - if (oldFrontend != null) { - const oldAppState = await oldFrontend.getAppStateAsync(); - await newFrontend.setAppStateAsync(oldAppState); - } const view = wasmView.current; setTimeout(() => { view.vtkScene.resizeAsync(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx index 3b031c32..069acfba 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -399,7 +399,7 @@ describe('setAppStateAsync holds a delivered variable block', () => { }); }); - test("the client's own state carried back leaves the held records untouched", async () => { + test('a state with no variable block leaves the held records untouched', async () => { // The client's own state carries `ui.darkTheme`, whose branch fetches // the two theme stylesheets; jsdom has no `fetch`. const globals = globalThis as unknown as { fetch: unknown }; From 9c7f77b73bb388d18455278a6d20e8af2e7e540e Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:45:49 +0000 Subject: [PATCH 4/5] chore: adding changelog file 165.maintenance.md [dependabot-skip] --- doc/changelog.d/165.maintenance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/165.maintenance.md diff --git a/doc/changelog.d/165.maintenance.md b/doc/changelog.d/165.maintenance.md new file mode 100644 index 00000000..09870621 --- /dev/null +++ b/doc/changelog.d/165.maintenance.md @@ -0,0 +1 @@ +[Remote rendering 3.6a] Clean ups after moving authority to server From 72ff4298814669831b6e1f2a015d62ffb719c44f Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:47:16 +0000 Subject: [PATCH 5/5] chore: adding changelog file 165.maintenance.md [dependabot-skip] --- doc/changelog.d/165.maintenance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/165.maintenance.md b/doc/changelog.d/165.maintenance.md index 09870621..ac592c5c 100644 --- a/doc/changelog.d/165.maintenance.md +++ b/doc/changelog.d/165.maintenance.md @@ -1 +1 @@ -[Remote rendering 3.6a] Clean ups after moving authority to server +[Remote rendering 3.6a] Cleanups after moving authority to server