diff --git a/doc/changelog.d/54.added.md b/doc/changelog.d/54.added.md new file mode 100644 index 00000000..11cc8ac9 --- /dev/null +++ b/doc/changelog.d/54.added.md @@ -0,0 +1 @@ +Remote rendering 3.1e - source per-part state from registry on save and restore it on load diff --git a/src/ansys/visor/viewer/models/common/visor_variable_state.py b/src/ansys/visor/viewer/models/common/visor_variable_state.py index 4034689a..335a23b2 100644 --- a/src/ansys/visor/viewer/models/common/visor_variable_state.py +++ b/src/ansys/visor/viewer/models/common/visor_variable_state.py @@ -2,7 +2,9 @@ from typing import List, Tuple -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_serializer + +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType class VisorVariableState(BaseModel): @@ -15,11 +17,23 @@ class VisorVariableState(BaseModel): a unique identifier for the variable: the variable name, type (POINT/CELL), and number of components, and computes the ranges across all datasets/parts that contain that variable. + ``array_name``, ``type`` and ``num_components`` are those same three identifying properties, carried + explicitly (rather than only opaquely inside ``id``) so a consumer can act on them without parsing the + client-built identifier. + In the future, the backend can own this, but for now we can treat this as passthrough data, as the id value is stable across sessions. """ model_config = ConfigDict(populate_by_name=True) id: str = Field(...) + array_name: str = Field(..., alias="arrayName") + type: VisorVtkVariableType = Field(...) + num_components: int = Field(..., alias="numComponents") magnitude_range: Tuple[float, float] | None = Field(default=None, alias="magnitudeRange") ranges: List[Tuple[float, float]] = Field(default_factory=list) + + @field_serializer("type") + def _serialize_type(self, value: VisorVtkVariableType) -> str: + """Emit the wire value (e.g. "POINT"/"CELL") for both dict-mode and JSON-mode dumps.""" + return value.value 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 be12ddd3..241d5e93 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 @@ -1,14 +1,61 @@ """Model for persisted scene state (cross-session).""" -from typing import Dict +from typing import Any, Dict, Mapping -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState +#: Separator used by the client when it mints a variable identifier. See +#: ``VisorSpectrumManager.tryAddSpectrumInfo``, which builds the identifier as +#: ``` `${type}::${name}::${numComponents}` ```. +_IDENTIFIER_SEPARATOR = "::" + +def derive_variable_fields_from_identifier(identifier: Any) -> Dict[str, Any] | None: + """Recover ``type``, ``array_name`` and ``num_components`` from a variable identifier. + + This is the inverse of the client's creation of the variable ID, and the only place the + identifier is parsed. The array name is in the middle field and is user data, so it may + contain the separator: the split is anchored at both ends rather than left to right, and + ``"POINT::stress::yy::3"`` yields the array name ``"stress::yy"``. + + Returns ``None`` when the identifier does not spell out three usable fields, meaning + "cannot be derived" rather than "derived to a default". Callers leave such an + entry untouched so ordinary validation reports the missing fields. + """ + if not isinstance(identifier, str): + return None + + # Strip out the last field, which is the number of components. + # e.g. "POINT::stress::yy::3".rpartition("::") -> ("POINT::stress::yy", "::", "3") + head, separator, num_components_str = identifier.rpartition(_IDENTIFIER_SEPARATOR) + if not separator: + return None + + try: + num_components = int(num_components_str) + except (TypeError, ValueError): + return None + + # Strip out the first field, which is the variable type. The remainder is the array name, + # which may contain the separator. + # e.g. "POINT::stress::yy".partition("::") -> ("POINT", "::", "stress::yy") + variable_type, separator, array_name = head.partition(_IDENTIFIER_SEPARATOR) + if not separator: + return None + + if not variable_type or not array_name: + return None + + return { + "type": variable_type, + "array_name": array_name, + "num_components": num_components, + } + class PersistedSceneState(BaseModel): """Persisted (cross-session) scene state. @@ -32,3 +79,56 @@ class PersistedSceneState(BaseModel): dataset_states: Dict[str, "PersistedDatasetState"] = Field(default_factory=dict) variable_states: Dict[str, "VisorVariableState"] = Field(default_factory=dict) model_config = ConfigDict(arbitrary_types_allowed=True) + + @field_validator("variable_states", mode="before") + @classmethod + 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.spectrum_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. + """ + if not isinstance(value, Mapping): + return value + + out: Dict[Any, Any] = {} + for key, entry in value.items(): + out[key] = cls._derive_missing_identity_fields_for_entry(key, entry) + return out + + @staticmethod + def _derive_missing_identity_fields_for_entry(key: Any, entry: Any) -> Any: + """Return *entry* with any absent identity fields derived from its identifier.""" + if isinstance(entry, VisorVariableState) or not isinstance(entry, Mapping): + return entry + + # A value that is present but null counts as present and is left + # to fail validation. + missing = [ + field + for field, alias in ( + ("array_name", "arrayName"), + ("type", "type"), + ("num_components", "numComponents"), + ) + if field not in entry and alias not in entry + ] + if not missing: + return entry + + identifier = entry.get("id") + if not isinstance(identifier, str) or not identifier: + identifier = key + + derived = derive_variable_fields_from_identifier(identifier) + if derived is None: + return entry + + filled = dict(entry) + for field in missing: + filled[field] = derived[field] + return filled + diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index a7cbd706..d98277f1 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -150,10 +150,23 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1: Capture the current viewer state and return it as a :class:`PersistedViewerStateV1`. - The renderer-specific part of state capture is handled by - :meth:`_get_runtime_state_async`. + 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. + + 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. """ runtime_state = await self._get_runtime_state_async(timeout) + + with self._vtk_lock: + registry_dataset_states = { + dataset_id: dataset_state.model_copy(deep=True) + for dataset_id, dataset_state in self._dataset_registry.runtime_state_dict.items() + } + runtime_state.scene.dataset_states = registry_dataset_states + persisted = self._state_mapper.runtime_to_persisted(runtime_state) return persisted @@ -166,7 +179,9 @@ def apply_state(self, state: PersistedViewerStateV1): :meth:`_apply_runtime_state_to_render`. Holds ``_vtk_lock`` for the whole body: the delegated step mutates - VTK and pushes to the frontend. + VTK and pushes to the frontend. The critical section deliberately + spans the outbound bridge call and the flush that follows it — the + unit the lock protects is the compound sequence, not the VTK work. """ with self._vtk_lock: # Apply UI settings @@ -175,10 +190,13 @@ def apply_state(self, state: PersistedViewerStateV1): # Transform the frontend PersistedViewerStateV1 -> RuntimeAppState runtime_app_state = self._state_mapper.persisted_to_runtime(state) - # Renderer-specific: flush VTK window and notify frontend (wasm), or - # push camera to vtkCamera (RCA), or no-op (headless). + self._restore_part_states_from_runtime(runtime_app_state) + self._apply_runtime_state_to_render(runtime_app_state) + # Note: There is intentionally no wasm flush here: the bridge call is fire-and-forget, so a flush + # at this point races the client's rebuild against a half-written object graph. + def get_scene_details(self) -> VisorSceneDetails: """Return the VisorState.""" if self._scene_graph is None: @@ -495,6 +513,206 @@ def clear_part_color_variable(self, node_id: int) -> None: return self._renderer.clear_color_variable(node_id) + def _restore_part_states_from_runtime(self, runtime_app_state: "RuntimeAppState") -> None: + """ + Restore per-part state from a runtime app state, on the load path. + + Replaces the part states of each dataset named in the supplied states, + then applies every part to this process's VTK pipeline through ``IRenderer``. + Datasets the registry does not hold are skipped and logged. Every other failure + is a logged no-op. + + Callers must hold ``_vtk_lock``. + """ + dataset_states = runtime_app_state.scene.dataset_states or {} + variable_states = runtime_app_state.scene.spectrum_states or {} + + self._dataset_registry.replace_part_states(dataset_states) + + for dataset_id, dataset_state in dataset_states.items(): + dataset = self._dataset_registry.datasets.get(dataset_id) + if dataset is None: + logger.warning( + "_restore_part_states_from_runtime: dataset %s is not registered; " + "its part state was not applied to the pipeline.", dataset_id + ) + continue + + # Per-part variable metadata, keyed by part id: one entry per + # non-empty leaf. Built once per dataset rather than per part. + variables_by_part = { + entry.part_id: entry.variables for entry in dataset.list_variables() + } + + for part_id, part_state in dataset_state.part_states.items(): + self._restore_one_part_state( + part_id, part_state, variable_states, variables_by_part.get(part_id) + ) + + def _restore_one_part_state( + self, + part_id: int, + part_state, + variable_states: dict, + part_variables, + ) -> None: + """ + Apply one restored part record to the pipeline. + + A ``None`` field means ""this record says nothing about that property", + not "reset it to the default", so nothing is applied for it. + + The color is validated to exactly three elements here; a malformed + color is a logged no-op and the same guard covers the color the + selection branch reads. The registry keeps the malformed value it was + loaded with; requiring on load would make the next save silently + rewrite the user's file. + """ + if part_state.visible is not None: + self._renderer.apply_visibility(part_id, part_state.visible) + + if part_state.opacity is not None: + self._renderer.apply_opacity(part_id, part_state.opacity) + + stored_rgb = part_state.diffuse_rgb + valid_rgb = None + if stored_rgb is not None: + if len(stored_rgb) == 3: + valid_rgb = stored_rgb + else: + logger.warning( + "_restore_one_part_state: part %s has a diffuse colour of %s elements, " + "not 3; leaving the pipeline colour unchanged.", part_id, len(stored_rgb) + ) + + if valid_rgb is not None: + self._renderer.apply_diffuse_color(part_id, valid_rgb[0], valid_rgb[1], valid_rgb[2]) + + if part_state.selected is not None: + selection_rgb = ( + valid_rgb if valid_rgb is not None else list(VisorColors.DefaultMeshColor) + ) + self._renderer.apply_selected(part_id, part_state.selected, selection_rgb) + + self._restore_part_color_variable(part_id, part_state, variable_states, part_variables) + + def _restore_part_color_variable( + self, + part_id: int, + part_state, + variable_states: dict, + part_variables, + ) -> None: + """ + Restore one part's color-variable reference, or clear it. + + The reference is a compound value, set and cleared as a unit, so if + either the identifier or component is missing, it is a logged no-op. + + A stored component of ``-1`` reads ``magnitude_range``; 0 or greater + reads ``ranges[component]``. Any other value is refused. + + The array is resolved against the server's per-part variable metadata, + and its width is checked against the stored component count: two parts + can carry same-named arrays of different widths, which the application + treats as different quantities. + """ + variable_id = part_state.spectrum_id + component = part_state.spectrum_component + + if variable_id is None: + if component is not None: + logger.warning( + "_restore_part_color_variable: part %s stores component %s with no variable " + "identifier; not clearing and not applying.", part_id, component + ) + return + self._renderer.clear_color_variable(part_id) + return + + if component is None: + logger.warning( + "_restore_part_color_variable: part %s stores variable '%s' with no component; " + "skipping.", part_id, variable_id + ) + return + + variable_state = variable_states.get(variable_id) + if variable_state is None: + logger.warning( + "_restore_part_color_variable: no variable entry for '%s' (part %s); skipping.", + variable_id, part_id + ) + return + + if component == -1: + value_range = variable_state.magnitude_range + elif component >= 0: + if component >= len(variable_state.ranges): + logger.warning( + "_restore_part_color_variable: component %s is outside the %s stored ranges " + "for '%s' (part %s); skipping.", + component, len(variable_state.ranges), variable_id, part_id + ) + return + value_range = variable_state.ranges[component] + else: + logger.warning( + "_restore_part_color_variable: component %s for '%s' (part %s) is neither the " + "magnitude sentinel (-1) nor a component index; skipping.", + component, variable_id, part_id + ) + return + + if value_range is None: + logger.warning( + "_restore_part_color_variable: no stored range for component %s of '%s' " + "(part %s); skipping.", component, variable_id, part_id + ) + return + + if part_variables is None: + logger.warning( + "_restore_part_color_variable: no variable metadata for part %s; skipping.", + part_id + ) + return + + resolved = next( + ( + variable for variable in part_variables + if variable.name == variable_state.array_name + and variable.type is variable_state.type + ), + None, + ) + if resolved is None: + logger.warning( + "_restore_part_color_variable: array '%s' (%s) not found on part %s; skipping.", + variable_state.array_name, variable_state.type, part_id + ) + return + + if resolved.num_components != variable_state.num_components: + logger.warning( + "_restore_part_color_variable: array '%s' on part %s has %s components, the " + "stored variable has %s; the part does not participate in this variable.", + variable_state.array_name, part_id, + resolved.num_components, variable_state.num_components + ) + return + + min_val, max_val = value_range + self._renderer.apply_color_variable( + part_id, + variable_id, + variable_state.type, + variable_state.array_name, + component, + min_val, + max_val, + ) + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py b/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py index 6df9034b..2732d4f6 100644 --- a/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py +++ b/src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py @@ -73,6 +73,11 @@ def persisted_to_runtime(self, state: PersistedViewerStateV1) -> RuntimeAppState """Convert PersistedViewerStateV1 back to runtime RuntimeAppState. Logic parity with ``VisorScene._persisted_to_runtime``. + + Returns the runtime dataset states on the ``RuntimeAppState``; it does not + assign them to ``VisorDataset.state``. The registry is populated by + :meth:`VisorSceneBase._restore_part_states_from_runtime`. + """ # UI settings ui_state = state.ui diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index a2cc9e19..56860b5f 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -260,6 +260,9 @@ export class VisorFrontend { for (const spectrumInfo of spectrumInfos.array) { const spectrumState = new VisorSpectrumState(); spectrumState.setId(spectrumInfo.id.toString()); + spectrumState.setArrayName(spectrumInfo.name); + spectrumState.setType(spectrumInfo.type); + spectrumState.setNumComponents(spectrumInfo.numComponents); const magnitudeRange = spectrumInfo.getRangeInfo(-1); if (magnitudeRange == null) { throw new Error(`range at component ${-1} not found`); diff --git a/src/ansys/visor/visor-client/src/state/appstate/VisorSpectrumState.tsx b/src/ansys/visor/visor-client/src/state/appstate/VisorSpectrumState.tsx index a9eecd08..c2151b7f 100644 --- a/src/ansys/visor/visor-client/src/state/appstate/VisorSpectrumState.tsx +++ b/src/ansys/visor/visor-client/src/state/appstate/VisorSpectrumState.tsx @@ -1,13 +1,19 @@ import { ensureArray, ensureNumberArray, + ensureNumber, + ensureString, JsonDict, parseState, StateInput, } from './VisorStateCommon.tsx'; +import type { FieldAssociation } from './vtkInfo/VisorVtkDataArray.tsx'; export default class VisorSpectrumState { private _id: string = ''; + private _arrayName: string = ''; + private _type: FieldAssociation | undefined = undefined; + private _numComponents: number = 0; private _magnitudeRange: number[] | undefined = undefined; private _ranges: (number[] | undefined)[] = []; @@ -28,6 +34,36 @@ export default class VisorSpectrumState { } } + get arrayName(): string { + return this._arrayName; + } + + setArrayName(val: string | null | undefined): void { + if (val != null) { + this._arrayName = ensureString(val, 'val'); + } + } + + get type(): FieldAssociation | undefined { + return this._type; + } + + setType(val: FieldAssociation | null | undefined): void { + if (val != null) { + this._type = val; + } + } + + get numComponents(): number { + return this._numComponents; + } + + setNumComponents(val: number | null | undefined): void { + if (val != null) { + this._numComponents = ensureNumber(val, 'val'); + } + } + get magnitudeRange(): number[] | undefined { return this._magnitudeRange; } @@ -83,6 +119,11 @@ export default class VisorSpectrumState { } this.setId(thisId); + this.setArrayName(data.arrayName === undefined ? this._arrayName : data.arrayName); + this.setType(data.type === undefined ? this._type : data.type); + this.setNumComponents( + data.numComponents === undefined ? this._numComponents : data.numComponents + ); this.setMagnitudeRange( data.magnitudeRange === undefined ? this._magnitudeRange : data.magnitudeRange, replace @@ -99,6 +140,9 @@ export default class VisorSpectrumState { toDict(): JsonDict { return { id: this.id, + arrayName: this.arrayName, + type: this.type, + numComponents: this.numComponents, magnitudeRange: this.magnitudeRange, ranges: this.ranges, }; diff --git a/tests/integration/test_save_load_state.py b/tests/integration/test_save_load_state.py index f19be965..415901b4 100644 --- a/tests/integration/test_save_load_state.py +++ b/tests/integration/test_save_load_state.py @@ -29,6 +29,8 @@ tests/files/simple_multiblock.vtkhdf — VTK MultiBlockDataSet """ +import asyncio +import json import os from unittest.mock import MagicMock, patch @@ -41,9 +43,15 @@ from ansys.visor.viewer.app.visor_vtk import VisorVTK from ansys.visor.viewer.core.metadata import ExtendedMetadata +from ansys.visor.viewer.models.common.part_properties import PartProperties from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState 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 @@ -370,3 +378,114 @@ def test_stale_snapshot_deleted_after_dataset_removed(self, file_io, tmp_path): assert plate_snap.exists() assert not mesh_snap.exists() + +# ================================================================== # +# Registry-sourced save, and registry restore on load +# ================================================================== # + +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. + + 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. + """ + + 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.""" + 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] + + # Mutate through the coordinator, i.e. exactly what a trigger does. + iface._scene.set_part_opacity(part_id, 0.25) + 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( + dark_mode=False, + unit="m", + dataset_states={ + dataset_id: RuntimeDatasetState( + id=dataset_id, + part_states={ + part_id: RuntimePartProperties(id=part_id, opacity=0.99, visible=True) + }, + ) + }, + ) + + 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) + + # 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] + + 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.""" + original = file_to_dataset(_vtp_path()) + snap = file_io.get_persisted_dataset_path(str(tmp_path), "plate") + file_io.write_dataset(snap, original) + + state = PersistedViewerStateV1.from_components( + ui_state=VisorUIState(), + unit="m", + orthographic_enabled=None, + cross_section_enabled=None, + edges_enabled=None, + bounding_box_enabled=None, + datasets={ + "plate": PersistedDatasetState( + serialized_dataset_path=str(snap), + parts={ + "plate": PartProperties( + opacity=0.25, + visible=False, + selected=True, + diffuse_rgb=[1.0, 0.0, 0.0], + color_by="POINT::pressure::1", + color_by_component=0, + ) + }, + ) + }, + ) + file_io.write_state(str(tmp_path), state) + + # No browser: the bridge push and the wasm flush are not exercised + # in-process. The registry restore must not depend on either. + iface._scene._apply_runtime_state_to_render = MagicMock() + iface._scene._renderer.flush_wasm_state = MagicMock() + + assert iface._scene.dataset_count == 0 + iface.load_state(str(tmp_path)) + + dataset = next(iter(iface._scene.datasets.values())) + part_id = dataset.part_index.part_ids[0] + record = iface._scene._dataset_registry.get_part_state(part_id) + + assert record.opacity == 0.25 + assert record.visible is False + assert record.selected is True + assert record.diffuse_rgb == [1.0, 0.0, 0.0] + assert record.spectrum_id == "POINT::pressure::1" + assert record.spectrum_component == 0 + + diff --git a/tests/unit/models/test_persisted_scene_state.py b/tests/unit/models/test_persisted_scene_state.py new file mode 100644 index 00000000..a8548f08 --- /dev/null +++ b/tests/unit/models/test_persisted_scene_state.py @@ -0,0 +1,381 @@ +"""Unit tests for the persisted-scene-state identity backfill. + +Two things are under test: + +* :func:`derive_variable_fields_from_identifier`, exercised directly against + hand-written literal identifiers, and +* the ``variable_states`` before-validator, exercised through + ``PersistedViewerStateV1.model_validate_json`` on literal 1.0-shaped + documents. + +Neither the parser's own separator constant nor any client-shaped helper is +imported or reused here: every identifier below is typed out by hand, so a test +cannot agree with the parser by construction. +""" + +import json + +import pytest +from pydantic import ValidationError + +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +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.persist.scene.persisted_scene_state import ( + PersistedSceneState, + derive_variable_fields_from_identifier, +) + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _document(variable_states: dict, version: str = "1.0") -> str: + """A save-file document in the 1.0 shape, as JSON text. + + Only the envelope is assembled here; every identifier and every entry is + written out by hand at the call site. + """ + return json.dumps({"version": version, "scene": {"variable_states": variable_states}}) + + +def _entry(identifier: str) -> dict: + """A variable-state entry as saved before the identity fields existed. + + ``id``, ``magnitudeRange`` and ``ranges`` are all a 1.0 entry ever carried. + """ + return { + "id": identifier, + "magnitudeRange": [0.0, 1.0], + "ranges": [[0.0, 1.0]], + } + + +def _load(document: str) -> PersistedViewerStateV1: + return PersistedViewerStateV1.model_validate_json(document) + + +def _load_one(identifier: str, entry: dict | None = None): + """Load a one-entry 1.0 document keyed by *identifier* and return the parsed entry.""" + entry = _entry(identifier) if entry is None else entry + return _load(_document({identifier: entry})).scene.variable_states[identifier] + + +# ------------------------------------------------------------------ +# The derivation, tested directly against hand-written literals +# ------------------------------------------------------------------ + +def test_derives_the_three_fields_from_a_plain_identifier(): + """A plain identifier yields type, array name and component count.""" + assert derive_variable_fields_from_identifier("POINT::pressure::1") == { + "type": "POINT", + "array_name": "pressure", + "num_components": 1, + } + + +def test_derives_when_the_array_name_contains_the_separator(): + """Array names are user data and may contain the separator character. + + A left-to-right split on the separator would truncate ``stress::yy`` to + ``stress``; splitting from the right by field count does not. + """ + assert derive_variable_fields_from_identifier("POINT::stress::yy::3") == { + "type": "POINT", + "array_name": "stress::yy", + "num_components": 3, + } + + +def test_derives_when_the_array_name_contains_several_separators(): + """More than one embedded separator is still kept verbatim.""" + derived = derive_variable_fields_from_identifier("CELL::a::b::c::2") + + assert derived["array_name"] == "a::b::c" + assert derived["type"] == "CELL" + assert derived["num_components"] == 2 + + +def test_same_array_name_different_component_counts_stay_distinct(): + """Two variables sharing an array name are told apart by component count.""" + scalar = derive_variable_fields_from_identifier("POINT::velocity::1") + vector = derive_variable_fields_from_identifier("POINT::velocity::3") + + assert scalar["array_name"] == vector["array_name"] == "velocity" + assert scalar["num_components"] == 1 + assert vector["num_components"] == 3 + assert scalar != vector + + +def test_cell_association_is_derived(): + """The type field is read from the identifier, not assumed to be POINT.""" + assert derive_variable_fields_from_identifier("CELL::volume::1")["type"] == "CELL" + + +@pytest.mark.parametrize("identifier", ["pressure", "POINT::pressure"]) +def test_returns_none_when_the_identifier_has_too_few_parts(identifier): + """Fewer than three fields cannot be derived, and are not guessed at.""" + assert derive_variable_fields_from_identifier(identifier) is None + + +def test_returns_none_when_the_component_count_is_not_an_integer(): + """A non-integer trailing field is refused rather than coerced.""" + assert derive_variable_fields_from_identifier("POINT::pressure::many") is None + + +@pytest.mark.parametrize("identifier", ["POINT::::1", "::pressure::1"]) +def test_returns_none_when_a_field_is_structurally_present_but_empty(identifier): + """Structural success is not semantic success. + + ``POINT::::1`` has three structural parts but an empty array name, and + ``::pressure::1`` an empty type. Filling either would produce a variable + state naming something that cannot exist, which then fails silently + downstream as an unresolvable-array no-op. Both are refused so that the + failure is a loud missing-field error instead. + """ + assert derive_variable_fields_from_identifier(identifier) is None + + +# ------------------------------------------------------------------ +# The fill, through literal 1.0-shaped documents +# ------------------------------------------------------------------ + +def test_v1_0_document_omitting_the_three_fields_parses_and_derives(): + """An old save file loads, and the absent fields are derived on ingest.""" + entry = _load_one("POINT::pressure::1") + + assert entry.array_name == "pressure" + assert entry.type is VisorVtkVariableType.POINT + assert entry.num_components == 1 + # The rest of the entry survives untouched. + assert entry.magnitude_range == (0.0, 1.0) + assert entry.ranges == [(0.0, 1.0)] + + +def test_v1_0_document_with_an_array_name_containing_the_separator_parses(): + """The embedded-separator case survives the whole ingest path, not just the split.""" + entry = _load_one("POINT::stress::yy::3") + + assert entry.array_name == "stress::yy" + assert entry.num_components == 3 + + +def test_v1_0_document_keeps_two_variables_sharing_an_array_name_distinct(): + """Two entries differing only in component count remain two entries.""" + document = _document( + { + "POINT::velocity::1": _entry("POINT::velocity::1"), + "POINT::velocity::3": _entry("POINT::velocity::3"), + } + ) + + variable_states = _load(document).scene.variable_states + + assert len(variable_states) == 2 + assert variable_states["POINT::velocity::1"].num_components == 1 + assert variable_states["POINT::velocity::3"].num_components == 3 + assert ( + variable_states["POINT::velocity::1"].array_name + == variable_states["POINT::velocity::3"].array_name + == "velocity" + ) + + +# ------------------------------------------------------------------ +# What the fill must not do +# ------------------------------------------------------------------ + +def test_present_fields_are_not_overwritten_by_derivation(): + """An explicit value wins over anything the identifier would imply.""" + entry = _entry("POINT::pressure::1") + entry["arrayName"] = "explicitly_named" + entry["type"] = "CELL" + entry["numComponents"] = 9 + + stored = _load_one("POINT::pressure::1", entry) + + assert stored.array_name == "explicitly_named" + assert stored.type is VisorVtkVariableType.CELL + assert stored.num_components == 9 + + +def test_camel_case_aliases_are_recognised_as_present(): + """A field supplied only under its wire alias is not treated as absent.""" + entry = _entry("POINT::pressure::1") + entry["arrayName"] = "supplied_by_alias" + + stored = _load_one("POINT::pressure::1", entry) + + assert stored.array_name == "supplied_by_alias" + # The two that really were absent are still derived. + assert stored.type is VisorVtkVariableType.POINT + assert stored.num_components == 1 + + +def test_snake_case_names_are_recognised_as_present(): + """The same holds for the Python field names, since populate_by_name is set.""" + entry = _entry("POINT::pressure::1") + entry["num_components"] = 7 + + stored = _load_one("POINT::pressure::1", entry) + + assert stored.num_components == 7 + assert stored.array_name == "pressure" + + +# ------------------------------------------------------------------ +# The write path +# ------------------------------------------------------------------ + +def test_already_built_instances_pass_through_untouched(): + """The write path hands the validator models, not dicts, and keeps them. + + The mapper passes the runtime dictionary through wholesale, so the values + arriving here are the very objects the caller built. They must not be + rebuilt, copied, or re-derived from their identifiers. + """ + built = VisorVariableState( + id="POINT::pressure::1", + array_name="pressure", + type=VisorVtkVariableType.POINT, + num_components=1, + ) + + scene = PersistedSceneState(variable_states={"POINT::pressure::1": built}) + + assert scene.variable_states["POINT::pressure::1"] is built + + +def test_instances_are_not_re_derived_from_a_disagreeing_identifier(): + """A model whose fields disagree with its id keeps its fields.""" + built = VisorVariableState( + id="POINT::pressure::1", + array_name="something_else", + type=VisorVtkVariableType.CELL, + num_components=4, + ) + + scene = PersistedSceneState(variable_states={"POINT::pressure::1": built}) + + stored = scene.variable_states["POINT::pressure::1"] + assert stored.array_name == "something_else" + assert stored.type is VisorVtkVariableType.CELL + assert stored.num_components == 4 + + +# ------------------------------------------------------------------ +# Failures stay loud +# ------------------------------------------------------------------ + +def test_unparseable_identifier_raises_validation_error(): + """An identifier that cannot be derived from fails, rather than filling a guess.""" + document = _document({"POINT::pressure": _entry("POINT::pressure")}) + + with pytest.raises(ValidationError) as excinfo: + _load(document) + + # Pydantic reports missing fields under the spelling it validated by, which + # for a document read off the wire is the alias. + reported = {error["loc"][-1] for error in excinfo.value.errors()} + assert {"arrayName", "type", "numComponents"} <= reported + + +def test_empty_array_name_in_the_identifier_raises_validation_error(): + """``POINT::::1`` reaches validation unfilled, and fails there.""" + document = _document({"POINT::::1": _entry("POINT::::1")}) + + with pytest.raises(ValidationError) as excinfo: + _load(document) + + reported = {error["loc"][-1] for error in excinfo.value.errors()} + assert {"arrayName", "type", "numComponents"} <= reported + + +def test_explicit_null_still_fails_rather_than_being_backfilled(): + """A present-but-null field is present, and an invalid value, not an absent one.""" + entry = _entry("POINT::pressure::1") + entry["arrayName"] = None + + with pytest.raises(ValidationError): + _load(_document({"POINT::pressure::1": entry})) + + +def test_non_mapping_entry_raises_validation_error(): + """A malformed entry produces a ValidationError, never a TypeError.""" + document = json.dumps( + {"version": "1.0", "scene": {"variable_states": {"POINT::pressure::1": "not-a-mapping"}}} + ) + + with pytest.raises(ValidationError): + _load(document) + + +def test_non_mapping_variable_states_raises_validation_error(): + """The container itself being malformed is Pydantic's to report.""" + document = json.dumps({"version": "1.0", "scene": {"variable_states": "not-a-mapping"}}) + + with pytest.raises(ValidationError): + _load(document) + + +# ------------------------------------------------------------------ +# Which string the derivation reads +# ------------------------------------------------------------------ + +def test_identifier_is_taken_from_the_entry_id_when_it_disagrees_with_the_key(): + """The entry's own ``id`` is the identifier of record; the key is a copy of it. + + They agree in every file the client writes -- ``VisorSceneState`` keys the + dictionary by ``newState.id`` and re-emits ``id`` inside ``toDict()`` -- so + this pins the precedence rule explicitly rather than letting it go untested + because the two happen to coincide. + """ + entry = _entry("CELL::temperature::3") + + stored = _load_one("POINT::pressure::1", entry) + + assert stored.array_name == "temperature" + assert stored.type is VisorVtkVariableType.CELL + assert stored.num_components == 3 + + +def test_key_and_id_agreeing_derive_the_values_the_identifier_spells_out(): + """The normal case: key equals id, and both spell out the same three fields.""" + stored = _load_one("POINT::stress::yy::3") + + assert stored.id == "POINT::stress::yy::3" + assert stored.array_name == "stress::yy" + assert stored.type is VisorVtkVariableType.POINT + assert stored.num_components == 3 + + +def test_key_is_used_when_the_entry_carries_no_id(): + """With no ``id`` to read, the mapping key stands in for it. + + ``id`` is itself required, so such an entry still fails -- but it must fail + on ``id`` alone, which is what shows the three identity fields were filled + from the key rather than left absent. + """ + document = _document({"POINT::pressure::1": {"magnitudeRange": [0.0, 1.0], "ranges": []}}) + + with pytest.raises(ValidationError) as excinfo: + _load(document) + + reported = {error["loc"][-1] for error in excinfo.value.errors()} + assert reported == {"id"} + + +# ------------------------------------------------------------------ +# The version literal +# ------------------------------------------------------------------ + +def test_version_literal_is_1_0(): + """The persisted schema version is 1.0.""" + assert PersistedViewerStateV1().version == "1.0" + + +def test_a_document_declaring_version_1_1_is_rejected(): + """1.1 was never released as a schema version and must not be accepted.""" + with pytest.raises(ValidationError): + _load(_document({}, version="1.1")) + diff --git a/tests/unit/models/test_visor_save_state_response.py b/tests/unit/models/test_visor_save_state_response.py index 9c4b9983..f0de700c 100644 --- a/tests/unit/models/test_visor_save_state_response.py +++ b/tests/unit/models/test_visor_save_state_response.py @@ -112,3 +112,64 @@ def test_model_dump_contains_fields(monkeypatch): assert data["requestId"] == 5 assert "appState" in data + + +def test_save_path_rejects_a_variable_state_missing_the_identity_fields(): + """The save path must keep raising on a client that stops emitting the fields. + + ``VisorVariableState`` is shared between ``PersistedSceneState.variable_states`` + and ``RuntimeSceneState.spectrum_states``, so making the three identity + fields optional on the model would have relaxed this coercion too. The + tolerance for old save files lives on the persisted container instead, and + this pins the fact that it did not leak here: reads tolerate absence, + writes do not. + """ + payload = { + "requestId": 1, + "appState": { + "scene": { + "spectrumStates": { + "POINT::pressure::1": { + "id": "POINT::pressure::1", + "magnitudeRange": [0.0, 1.0], + "ranges": [[0.0, 1.0]], + } + } + } + }, + } + + with pytest.raises(ValidationError) as excinfo: + VisorSaveStateResponse.model_validate(payload) + + # Reported under the wire alias, which is the spelling validation ran by. + reported = {error["loc"][-1] for error in excinfo.value.errors()} + assert {"arrayName", "type", "numComponents"} <= reported + + +def test_save_path_accepts_a_variable_state_carrying_the_identity_fields(): + """The same payload with the three fields present validates, so the guard is specific.""" + payload = { + "requestId": 1, + "appState": { + "scene": { + "spectrumStates": { + "POINT::pressure::1": { + "id": "POINT::pressure::1", + "arrayName": "pressure", + "type": "POINT", + "numComponents": 1, + "magnitudeRange": [0.0, 1.0], + "ranges": [[0.0, 1.0]], + } + } + } + }, + } + + resp = VisorSaveStateResponse.model_validate(payload) + + stored = resp.app_state.scene.spectrum_states["POINT::pressure::1"] + assert stored.array_name == "pressure" + assert stored.num_components == 1 + diff --git a/tests/unit/models/test_visor_variable_state.py b/tests/unit/models/test_visor_variable_state.py new file mode 100644 index 00000000..0dd42a72 --- /dev/null +++ b/tests/unit/models/test_visor_variable_state.py @@ -0,0 +1,26 @@ +"""Unit tests for VisorVariableState.""" + +import pytest +from pydantic import ValidationError + +from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState + +_VALID_KWARGS = { + "id": "POINT::displacement::3", + "array_name": "displacement", + "type": VisorVtkVariableType.POINT, + "num_components": 3, +} + + +@pytest.mark.parametrize("missing_field", ["array_name", "type", "num_components"]) +def test_construction_missing_required_field_raises(missing_field): + """Omitting any one of array_name, type, or num_components must raise, + proving each is required on its own rather than all three being + collectively-but-not-individually required.""" + kwargs = {k: v for k, v in _VALID_KWARGS.items() if k != missing_field} + + with pytest.raises(ValidationError): + VisorVariableState(**kwargs) + diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 922a0e13..cb385001 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -15,6 +15,8 @@ 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 @@ -25,14 +27,23 @@ from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType +from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState 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.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.vtk.datasets.visor_dataset_registry import VisorDatasetRegistry from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline from ansys.visor.viewer.vtk.scene.base import VisorSceneBase +from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables +from ansys.visor.viewer.vtk.variables.visor_variables import VisorVariable + +NODE_ID = 7 +SECOND_NODE_ID = 8 +UNKNOWN_NODE_ID = 999999 +DATASET_ID = 1 NODE_ID = 7 UNKNOWN_NODE_ID = 999999 @@ -98,16 +109,19 @@ def __exit__(self, exc_type, exc, tb): return False -def _make_part_dataset(dataset_id: int, part_ids, part_states=None): +def _make_part_dataset(dataset_id: int, part_ids, part_states=None, part_variables=None): """Dataset stand-in with a real PartIndex.part_ids and a real state object. Real (not MagicMock) state, so a registry write can be read back through - object identity rather than through a recorded call. + object identity rather than through a recorded call. ``part_variables`` + is the per-part variable metadata the restore path reads back through + ``VisorDataset.list_variables()`` — one ``VisorPartVariables`` per part. """ dataset = MagicMock() dataset.part_index = MagicMock() dataset.part_index.part_ids = list(part_ids) dataset.state = RuntimeDatasetState(id=dataset_id, part_states=part_states or {}) + dataset.list_variables.return_value = list(part_variables or []) return dataset @@ -540,6 +554,12 @@ def mocked_scene(renderer): s._dataset_registry.count = 1 s._renderer = MagicMock(name="renderer") s._state_mapper = MagicMock(name="state_mapper") + # apply_state now walks the mapped state's dataset_states, so the mapper + # must return a real RuntimeAppState rather than a MagicMock (whose + # dataset_states would be a MagicMock and raise on iteration). + s._state_mapper.persisted_to_runtime.return_value = RuntimeAppState.from_components( + dark_mode=False, unit="m", dataset_states={} + ) s._vtk_lock = _LockSpy() return s @@ -681,4 +701,533 @@ def test_the_scene_lock_is_reentrant(): assert scene._vtk_lock is not None +# =========================================================================== +# Save/load: the registry is the server-side authority for per-part state +# +# The save-path assertions (what get_state hands to the persist mapper) and +# the load-path assertions (the registry write, and the pipeline applies) are +# separate tests: either half can silently do nothing while the other works. +# Every expected value below is a hand-written literal. +# =========================================================================== + +FRONTEND_DATASET_ID = 4242 +VARIABLE_ID = "POINT::pressure::1" + +# The point array the `array_dataset` fixture carries, described the way the +# persisted variable entry describes it. Component 0's range and the +# magnitude range are deliberately different so a test can tell which branch +# the restore took. +PRESSURE_MAGNITUDE_RANGE = (0.0, 49.0) +PRESSURE_COMPONENT_RANGE = (10.0, 20.0) + + +def _pressure_variable() -> VisorVariable: + """Server-side per-part metadata for the fixture's "pressure" point array.""" + return VisorVariable( + index=0, + type=VisorVtkVariableType.POINT, + name="pressure", + num_components=1, + num_points=50, + ranges=[PRESSURE_COMPONENT_RANGE], + magnitude_range=PRESSURE_MAGNITUDE_RANGE, + ) + + +def _variable_state( + array_name="pressure", + var_type=VisorVtkVariableType.POINT, + num_components=1, + magnitude_range=PRESSURE_MAGNITUDE_RANGE, + ranges=(PRESSURE_COMPONENT_RANGE,), +) -> VisorVariableState: + """A persisted variable entry, as I6a stores it.""" + return VisorVariableState( + id=VARIABLE_ID, + array_name=array_name, + type=var_type, + num_components=num_components, + magnitude_range=magnitude_range, + ranges=list(ranges), + ) + + +def _seed_part_variables(registry, variables, part_id=NODE_ID, dataset_id=1): + """Give the registry's dataset per-part variable metadata for *part_id*.""" + registry.datasets[dataset_id].list_variables.return_value = [ + VisorPartVariables(part_id=part_id, part_name="part", variables=list(variables)) + ] + + +def _runtime_state(part_states, variable_states=None, dataset_id=1): + """A real RuntimeAppState carrying the given per-part records.""" + return RuntimeAppState.from_components( + dark_mode=False, + unit="m", + dataset_states={ + dataset_id: RuntimeDatasetState(id=dataset_id, part_states=part_states) + }, + variable_states=variable_states or {}, + ) + + +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( + dark_mode=False, + unit="m", + dataset_states={ + FRONTEND_DATASET_ID: RuntimeDatasetState( + id=FRONTEND_DATASET_ID, + part_states={NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.99)}, + ) + }, + ) + + +def _capture_persist_input(scene, frontend_state): + """Wire get_state to *frontend_state* and capture what the mapper receives.""" + 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 + + +def _apply(scene, runtime_state): + """Run apply_state with the mapper stubbed to return *runtime_state*.""" + scene._state_mapper = MagicMock(name="state_mapper") + scene._state_mapper.persisted_to_runtime.return_value = runtime_state + return scene.apply_state(MagicMock(name="persisted_state")) + + +class _DepthRecordingRegistry(VisorDatasetRegistry): + """Registry that records the scene's lock depth when it is read.""" + + def __init__(self, scene): + super().__init__() + self._scene = scene + self.depth_at_read = None + + @property + def runtime_state_dict(self): + self.depth_at_read = self._scene._vtk_lock.depth + return VisorDatasetRegistry.runtime_state_dict.fget(self) + + +@pytest.fixture +def second_pipeline(renderer, array_dataset): + """A second real VtkNodePipeline, registered under SECOND_NODE_ID.""" + pipe = VtkNodePipeline.from_dataset(array_dataset) + renderer._pipelines[SECOND_NODE_ID] = pipe + return pipe + + +# --------------------------------------------------------------------------- +# Save path +# --------------------------------------------------------------------------- + +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()) + + asyncio.run(scene.get_state(timeout=1.0)) + + 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()) + + asyncio.run(scene.get_state(timeout=1.0)) + + assert spy_registry.depth_at_read >= 1 + assert scene._vtk_lock.depth == 0 + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +def test_get_state_snapshots_the_registry_rather_than_referencing_it(scene, registry): + """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()) + + asyncio.run(scene.get_state(timeout=1.0)) + registry.set_part_opacity(NODE_ID, 0.75) + + snapshot = captured["runtime_state"].scene.dataset_states[1] + assert snapshot is not live_state + assert snapshot.part_states[NODE_ID] is not live_state.part_states[NODE_ID] + assert snapshot.part_states[NODE_ID].opacity == 0.25 + + +def test_apply_state_pushes_a_json_encodable_runtime_state(scene, registry): + """What the bridge is handed survives JSON encoding. + + An enum-typed field reaching the transport unserialised failed the push + with wslink -32002 at I6a while every gate passed, because nothing in the + suite exercised the push payload. This asserts the payload of the object + apply_state hands to the renderer-specific step, not a model in isolation. + """ + pushed = {} + scene._apply_runtime_state_to_render = lambda state: pushed.update(state=state) + + _apply( + scene, + _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25, spectrum_id=VARIABLE_ID)}, + variable_states={VARIABLE_ID: _variable_state()}, + ), + ) + + encoded = json.dumps(pushed["state"].model_dump(by_alias=True)) + assert '"POINT"' in encoded + + +# --------------------------------------------------------------------------- +# Load path — store half and apply half, separately +# --------------------------------------------------------------------------- + +def test_apply_state_populates_the_registry_from_the_persisted_state(scene, registry): + """Store half: the registry carries the restored record.""" + _apply( + scene, + _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25, visible=False)} + ), + ) + + record = registry.get_part_state(NODE_ID) + assert record.opacity == 0.25 + assert record.visible is False + + +def test_apply_state_applies_every_part_to_the_pipeline( + scene, registry, pipeline, second_pipeline +): + """Apply half: both parts reach their own VTK objects.""" + registry.datasets[1] = _make_part_dataset(1, [NODE_ID, SECOND_NODE_ID]) + pipeline.actor.SetVisibility(1) + second_pipeline.actor.GetProperty().SetOpacity(1.0) + + _apply( + scene, + _runtime_state( + { + NODE_ID: RuntimePartProperties(id=NODE_ID, visible=False), + SECOND_NODE_ID: RuntimePartProperties(id=SECOND_NODE_ID, opacity=0.25), + } + ), + ) + + assert pipeline.actor.GetVisibility() == 0 + assert second_pipeline.actor.GetProperty().GetOpacity() == pytest.approx(0.25) + + +def test_apply_state_does_not_flush_after_the_bridge_call(scene, registry): + """Exactly one flush, and it is ordered after the bridge call returns.""" + order = [] + scene._apply_runtime_state_to_render = lambda state: order.append("bridge") + scene._renderer.flush_wasm_state = lambda: order.append("flush") + + _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25)})) + + assert order == ["bridge"] + + +def test_restore_part_states_holds_the_lock(scene, registry, pipeline): + """The lock is held at both halves inside the restore helper itself.""" + scene._vtk_lock = _LockSpy() + observed = {} + real_replace = registry.replace_part_states + + def _replace(dataset_states): + observed["store_depth"] = scene._vtk_lock.depth + return real_replace(dataset_states) + + def _apply_opacity(node_id, opacity): + observed["apply_depth"] = scene._vtk_lock.depth + + registry.replace_part_states = _replace + scene._renderer.apply_opacity = _apply_opacity + + _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID, opacity=0.25)})) + + assert observed["store_depth"] >= 1 + assert observed["apply_depth"] >= 1 + assert scene._vtk_lock.depth == 0 + + +def test_apply_state_unregistered_dataset_id_is_a_logged_skip(scene, pipeline): + """A dataset the registry does not have is skipped explicitly, not silently.""" + pipeline.actor.SetVisibility(1) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply( + scene, + _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, visible=False)}, + dataset_id=FRONTEND_DATASET_ID, + ), + ) + + assert mock_logger.warning.call_count == 1 + assert pipeline.actor.GetVisibility() == 1 + + +def test_apply_state_unknown_node_is_a_logged_no_op(scene, registry, pipeline): + """A part with no pipeline is a logged no-op, not a raise.""" + pipeline.actor.SetVisibility(1) + + with patch("ansys.visor.viewer.renderer.local_renderer.logger") as mock_logger: + result = _apply( + scene, + _runtime_state( + {UNKNOWN_NODE_ID: RuntimePartProperties(id=UNKNOWN_NODE_ID, visible=False)} + ), + ) + + assert result is None + assert mock_logger.debug.call_count >= 1 + assert pipeline.actor.GetVisibility() == 1 + + +def test_apply_state_short_diffuse_color_is_a_logged_no_op(scene, registry, pipeline): + """A malformed colour leaves the VTK object alone; the record keeps it.""" + pipeline.actor.GetProperty().SetDiffuseColor(0.1, 0.2, 0.3) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply( + scene, + _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, diffuse_rgb=[1.0, 0.0])} + ), + ) + + assert mock_logger.warning.call_count == 1 + assert pipeline.actor.GetProperty().GetDiffuseColor() == pytest.approx((0.1, 0.2, 0.3)) + assert registry.get_part_state(NODE_ID).diffuse_rgb == [1.0, 0.0] + + +# --------------------------------------------------------------------------- +# Load path — the colour-variable branch +# --------------------------------------------------------------------------- + +def _color_variable_state(component, **variable_kwargs): + """A part coloured by the fixture's point array, at *component*.""" + return _runtime_state( + { + NODE_ID: RuntimePartProperties( + id=NODE_ID, spectrum_id=VARIABLE_ID, spectrum_component=component + ) + }, + variable_states={VARIABLE_ID: _variable_state(**variable_kwargs)}, + ) + + +def _seed_unconfigured_mapper(pipeline): + """Distinctive, non-default mapper state so a no-op is visible as one.""" + pipeline.mapper.SetScalarVisibility(0) + pipeline.mapper.SetScalarRange(11.0, 22.0) + + +def test_apply_state_restores_the_magnitude_range_when_component_is_minus_one( + scene, registry, pipeline +): + """A stored component of -1 reads magnitude_range, not ranges[0].""" + _seed_part_variables(registry, [_pressure_variable()]) + + _apply(scene, _color_variable_state(-1)) + + assert pipeline.mapper.GetArrayName() == "pressure" + assert pipeline.mapper.GetScalarRange() == pytest.approx((0.0, 49.0)) + + +def test_apply_state_restores_the_per_component_range(scene, registry, pipeline): + """A stored component of 0 reads ranges[0], not magnitude_range.""" + _seed_part_variables(registry, [_pressure_variable()]) + + _apply(scene, _color_variable_state(0)) + + assert pipeline.mapper.GetArrayName() == "pressure" + assert pipeline.mapper.GetScalarRange() == pytest.approx((10.0, 20.0)) + + +def test_apply_state_negative_component_other_than_minus_one_is_a_logged_no_op( + scene, registry, pipeline +): + """-2 is neither the magnitude sentinel nor an index: no fallthrough.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, _color_variable_state(-2)) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + assert pipeline.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + + +def test_apply_state_component_beyond_the_stored_ranges_is_a_logged_no_op( + scene, registry, pipeline +): + """An index past the stored ranges applies nothing.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, _color_variable_state(3)) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + assert pipeline.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + + +def test_apply_state_absent_range_is_a_logged_no_op(scene, registry, pipeline): + """A variable entry with no magnitude range applies nothing.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, _color_variable_state(-1, magnitude_range=None)) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + assert pipeline.mapper.GetScalarRange() == pytest.approx((11.0, 22.0)) + + +def test_apply_state_unknown_variable_identifier_is_a_logged_no_op( + scene, registry, pipeline +): + """A stored identifier with no variable entry applies nothing.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + runtime = _runtime_state( + { + NODE_ID: RuntimePartProperties( + id=NODE_ID, spectrum_id=VARIABLE_ID, spectrum_component=0 + ) + }, + variable_states={}, + ) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, runtime) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_apply_state_unknown_array_name_is_a_logged_no_op(scene, registry, pipeline): + """An array the part does not carry applies nothing.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, _color_variable_state(0, array_name="no_such_array")) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_apply_state_array_width_mismatch_is_a_logged_no_op(scene, registry, pipeline): + """Same name and association, different width: a different quantity.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply( + scene, + _color_variable_state( + 0, num_components=3, ranges=((10.0, 20.0), (0.0, 1.0), (0.0, 2.0)) + ), + ) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_apply_state_clears_the_color_variable_when_none_is_stored( + scene, registry, pipeline +): + """No stored identifier and no stored component: the clear branch.""" + pipeline.mapper.SetScalarVisibility(1) + + _apply(scene, _runtime_state({NODE_ID: RuntimePartProperties(id=NODE_ID)})) + + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_apply_state_variable_id_without_component_is_a_logged_no_op( + scene, registry, pipeline +): + """Half a compound value: an identifier with no component.""" + _seed_part_variables(registry, [_pressure_variable()]) + _seed_unconfigured_mapper(pipeline) + runtime = _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, spectrum_id=VARIABLE_ID)}, + variable_states={VARIABLE_ID: _variable_state()}, + ) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, runtime) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 0 + + +def test_apply_state_component_without_variable_id_does_not_clear( + scene, registry, pipeline +): + """The mirror half: a component with no identifier must not fall through.""" + pipeline.mapper.SetScalarVisibility(1) + runtime = _runtime_state( + {NODE_ID: RuntimePartProperties(id=NODE_ID, spectrum_component=0)}, + variable_states={VARIABLE_ID: _variable_state()}, + ) + + with patch("ansys.visor.viewer.vtk.scene.base.logger") as mock_logger: + _apply(scene, runtime) + + assert mock_logger.warning.call_count == 1 + assert pipeline.mapper.GetScalarVisibility() == 1 + + diff --git a/tests/unit/vtk/scene/test_visor_state_mapper.py b/tests/unit/vtk/scene/test_visor_state_mapper.py index 89a07437..f16587e3 100644 --- a/tests/unit/vtk/scene/test_visor_state_mapper.py +++ b/tests/unit/vtk/scene/test_visor_state_mapper.py @@ -11,6 +11,7 @@ class FakeDataset: def __init__(self, name, did): self.name = name self.id = did + self.state = "untouched-sentinel" def runtime_to_persisted_state(self, state): return {"converted": state} @@ -243,3 +244,47 @@ def test_persisted_to_runtime_handles_none_variable_states(monkeypatch): mapper.persisted_to_runtime(state) assert captured["variable_states"] == {} + + +def test_persisted_to_runtime_does_not_write_back_to_dataset_state(monkeypatch): + """The mapper is not, and never has been, the registry-population path. + + It builds runtime dataset states and returns them; it never assigns them + onto ``VisorDataset.state``. That is the gap + ``VisorSceneBase._restore_part_states_from_runtime`` closes, so the + negative is pinned here rather than assumed. + """ + + dataset = FakeDataset(name="dsA", did=10) + registry = FakeRegistry(by_name={"dsA": dataset}) + + monkeypatch.setattr( + "ansys.visor.viewer.vtk.scene.visor_state_mapper.RuntimeAppState.from_components", + lambda **kwargs: kwargs, + ) + + state = type("Persisted", (), { + "ui": type("UI", (), {"dark_theme": False})(), + "scene": type("Scene", (), { + "unit": "m", + "camera": None, + "variable_states": {}, + "dataset_states": { + "dsA": type("DS", (), {"parts": {"p": 9}})() + }, + "cross_section": None, + "orthographic_enabled": False, + "cross_section_enabled": False, + "edges_enabled": False, + "bounding_box_enabled": False, + })(), + })() + + mapper = VisorStateMapper(registry) + result = mapper.persisted_to_runtime(state) + + # The converted state is returned ... + assert result["dataset_states"] == {10: {"rt": {"p": 9}}} + # ... and is not written back onto the dataset the registry holds. + assert dataset.state == "untouched-sentinel" +