diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py index 1503433a..6d5db9d7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py @@ -9,12 +9,24 @@ def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]: Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of each row is the "out" position) so there is one source of truth for what "out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check. + + Tolerates a deployment missing one or more filter axes (e.g. a simulated + session without the real beamline's filter_array_* devices), and `dev` + itself being None (the usual state of the module-level `dev = + builtins.__dict__.get("dev")` idiom used throughout this codebase before + a real BEC session has set it). BEC's device container raises + DeviceConfigError -- not AttributeError -- for a missing device, so a + plain `getattr(dev, axis_name, None)` does NOT safely skip it; wrapping + the lookup itself in try/except does, for both cases. """ offending = [] for axis_name, positions in zip( cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER ): - axis_obj = getattr(dev, axis_name, None) + try: + axis_obj = getattr(dev, axis_name, None) + except Exception: + axis_obj = None out_position = positions[0] if axis_obj is None or out_position is None: continue diff --git a/tests/tests_bec_ipython_client/test_filter_check.py b/tests/tests_bec_ipython_client/test_filter_check.py new file mode 100644 index 00000000..a45d6894 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_filter_check.py @@ -0,0 +1,81 @@ +"""Tests for filters_out_of_beam() (csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py). + +Regression coverage for a bug surfaced on a simulated flomni session: +tomo_scan_projection() -> filters_out_of_beam() crashed with +DeviceConfigError: Device filter_array_1_x does not exist, because the sim +config (unlike the real beamline's bl_endstation.yaml) doesn't define the +filter_array_*_x devices at all. The lookup used `getattr(dev, axis_name, +None)`, but BEC's device container raises DeviceConfigError (a plain +Exception, not AttributeError) for a missing device, so getattr's default +never applied. +""" + +from unittest import mock + +from bec_lib.bec_errors import DeviceConfigError + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import filters_out_of_beam + + +class _FakeDeviceContainer(dict): + """Stands in for BEC's real device-manager container: attribute access + for a present device returns it, for a missing one raises + DeviceConfigError (not AttributeError) -- see devicemanager.py's + __getattr__.""" + + def __getattr__(self, attr): + dev = self.get(attr) + if not dev: + raise DeviceConfigError(f"Device {attr} does not exist.") + return dev + + +def _axis_at(position: float): + axis = mock.MagicMock() + axis.readback.get.return_value = position + return axis + + +def test_filters_out_of_beam_dev_is_none(): + # The usual state of the module-level `dev` idiom before a real BEC + # session has set it (`dev = builtins.__dict__.get("dev")`) -- several + # LamNI/Flomni call sites pass this straight through. Must not raise. + all_out, offending = filters_out_of_beam(None) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_all_devices_missing_from_container(): + # Mirrors a simulated deployment: none of the filter_array_*_x devices + # exist at all. Must not raise, and must report "all out" (nothing to + # check -> vacuously true), matching the existing axis_obj-is-None skip. + dev = _FakeDeviceContainer() + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_some_devices_missing_from_container(): + # A mixed deployment: filter_array_1_x exists and is out of the beam, + # the other three axes don't exist at all. + dev = _FakeDeviceContainer(filter_array_1_x=_axis_at(25.0)) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_flags_axis_in_beam(): + dev = _FakeDeviceContainer( + filter_array_1_x=_axis_at(25.0), # out + filter_array_2_x=_axis_at(17.6), # NOT out (out position is 25.5) + ) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is False + assert offending == ["filter_array_2_x"]