diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 46c6b38..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -from typing import Any - -import pytest - -# Prevent pytest from catching exceptions when debugging in vscode so that break on -# exception works correctly (see: https://github.com/pytest-dev/pytest/issues/7409) -if os.getenv("PYTEST_RAISE", "0") == "1": - - @pytest.hookimpl(tryfirst=True) - def pytest_exception_interact(call: pytest.CallInfo[Any]): - if call.excinfo is not None: - raise call.excinfo.value - else: - raise RuntimeError( - f"{call} has no exception data, an unknown error has occurred" - ) - - @pytest.hookimpl(tryfirst=True) - def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): - raise excinfo.value - - -_detector_config_keys = [ - "auto_summation", - "beam_center_x", - "beam_center_y", - "bit_depth_image", - "bit_depth_readout", - "chi_increment", - "chi_start", - "compression", - "count_time", - "counting_mode", - "countrate_correction_applied", - "countrate_correction_count_cutoff", - "data_collection_date", - "description", - "detector_distance", - "detector_number", - "detector_readout_time", - "eiger_fw_version", - "element", - "extg_mode", - "fast_arm", - "flatfield_correction_applied", - "frame_count_time", - "frame_time", - "incident_energy", - "incident_particle_type", - "instrument_name", - "kappa_increment", - "kappa_start", - "mask_to_zero", - "nexpi", - "nimages", - "ntrigger", - "ntriggers_skipped", - "number_of_excluded_pixels", - "omega_increment", - "omega_start", - "phi_increment", - "phi_start", - "photon_energy", - "pixel_mask_applied", - "roi_mode", - "sample_name", - "sensor_material", - "sensor_thickness", - "software_version", - "source_name", - "threshold/1/energy", - "threshold/1/mode", - "threshold/1/number_of_excluded_pixels", - "threshold/2/energy", - "threshold/2/mode", - "threshold/2/number_of_excluded_pixels", - "threshold/difference/lower_threshold", - "threshold/difference/mode", - "threshold/difference/upper_threshold", - "threshold_energy", - "total_flux", - "trigger_mode", - "trigger_start_delay", - "two_theta_increment", - "two_theta_start", - "virtual_pixel_correction_applied", - "x_pixel_size", - "x_pixels_in_detector", - "y_pixel_size", - "y_pixels_in_detector", -] - -_detector_status_keys = [ - "humidity", - "link_0", - "link_1", - "series_unique_id", - "state", - "temperature", - "time", -] - -_stream_config_keys = [ - "format", - "header_appendix", - "header_detail", - "image_appendix", - "mode", -] -_stream_status_keys = ["dropped", "state"] -_monitor_config_keys = ["buffer_size", "discard_new", "mode"] -_monitor_status_keys = ["buffer_free", "dropped", "error", "state"] - - -@pytest.fixture -def detector_config_keys() -> list[str]: - return _detector_config_keys - - -@pytest.fixture -def detector_status_keys(): - return _detector_status_keys - - -@pytest.fixture -def monitor_config_keys(): - return _monitor_config_keys - - -@pytest.fixture -def monitor_status_keys(): - return _monitor_status_keys - - -@pytest.fixture -def stream_config_keys(): - return _stream_config_keys - - -@pytest.fixture -def stream_status_keys(): - return _stream_status_keys - - -@pytest.fixture -def keys_mapping() -> dict[str, list[str]]: - return { - "detector/api/1.8.0/status/keys": _detector_status_keys, - "detector/api/1.8.0/config/keys": _detector_config_keys, - "monitor/api/1.8.0/status/keys": _monitor_status_keys, - "monitor/api/1.8.0/config/keys": _monitor_config_keys, - "stream/api/1.8.0/status/keys": _stream_status_keys, - "stream/api/1.8.0/config/keys": _stream_config_keys, - } - - -@pytest.fixture -def put_response_mapping() -> dict[str, list[str]]: - time_keys = [ - "bit_depth_image", - "bit_depth_readout", - "count_time", - "countrate_correction_count_cutoff", - "frame_count_time", - "frame_time", - ] - energy_keys = [ - "element", - "flatfield", - "incident_energy", - "photon_energy", - "threshold/1/energy", - "threshold/1/flatfield", - "threshold/2/energy", - "threshold/2/flatfield", - "threshold_energy", - "wavelength", - ] - threshold_energy_keys = [ - "flatfield", - "threshold/1/energy", - "threshold/1/flatfield", - "threshold/2/flatfield", - "threshold_energy", - ] - return { - "auto_summation": ["auto_summation", "frame_count_time"], - "count_time": time_keys, - "frame_time": time_keys, - "flatfield": ["flatfield", "threshold/1/flatfield"], - "incident_energy": energy_keys, - "photon_energy": energy_keys, - "pixel_mask": ["pixel_mask", "threshold/1/pixel_mask"], - "threshold/1/flatfield": ["flatfield", "threshold/1/flatfield"], - "roi_mode": ["count_time", "frame_time", "roi_mode"], - "threshold_energy": threshold_energy_keys, - "threshold/1/energy": threshold_energy_keys, - "threshold/2/energy": [ - "flatfield", - "threshold/1/flatfield", - "threshold/2/energy", - "threshold/2/flatfield", - ], - "threshold/1/mode": ["threshold/1/mode", "threshold/difference/mode"], - "threshold/2/mode": ["threshold/2/mode", "threshold/difference/mode"], - "threshold/1/pixel_mask": ["pixel_mask", "threshold/1/pixel_mask"], - "threshold/difference/mode": ["difference_mode"], - # replicating API inconsistency - } diff --git a/tests/system/conftest.py b/tests/system/conftest.py new file mode 100644 index 0000000..3783427 --- /dev/null +++ b/tests/system/conftest.py @@ -0,0 +1,37 @@ +import signal +import subprocess +from pathlib import Path +from time import sleep + +import pytest + +from fastcs_eiger.eiger_controller import EigerController + +HERE = Path(__file__).parent + + +# Stolen from tickit-devices +# https://docs.pytest.org/en/latest/example/parametrize.html#indirect-parametrization +@pytest.fixture +def sim_eiger_controller(request): + """Subprocess that runs ``tickit all ``.""" + config_path: str = request.param + proc = subprocess.Popen( + ["tickit", "all", config_path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + # Wait until ready + while True: + line = proc.stdout.readline() + if "Starting HTTP server..." in line: + break + + sleep(3) + + yield EigerController("127.0.0.1", 8081) + + proc.send_signal(signal.SIGINT) + print(proc.communicate()[0]) diff --git a/tests/system/test_introspection.py b/tests/system/test_introspection.py index 2422e0a..27a22bc 100644 --- a/tests/system/test_introspection.py +++ b/tests/system/test_introspection.py @@ -1,9 +1,6 @@ import json import os -import signal -import subprocess from pathlib import Path -from time import sleep from typing import Any import pytest @@ -34,38 +31,6 @@ def _serialise_parameter(parameter: EigerParameter) -> dict: } -@pytest.fixture -def eiger_controller(): - yield EigerController("i04-1-eiger01", 80) - - -# Stolen from tickit-devices -# https://docs.pytest.org/en/latest/example/parametrize.html#indirect-parametrization -@pytest.fixture -def sim_eiger_controller(request): - """Subprocess that runs ``tickit all ``.""" - config_path: str = request.param - proc = subprocess.Popen( - ["tickit", "all", config_path], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - # Wait until ready - while True: - line = proc.stdout.readline() - if "Starting HTTP server..." in line: - break - - sleep(3) - - yield EigerController("127.0.0.1", 8081) - - proc.send_signal(signal.SIGINT) - print(proc.communicate()[0]) - - @pytest.mark.asyncio @pytest.mark.parametrize( "sim_eiger_controller", [str(HERE / "eiger.yaml")], indirect=True diff --git a/tests/system/test_system.py b/tests/system/test_system.py new file mode 100644 index 0000000..aa6ea26 --- /dev/null +++ b/tests/system/test_system.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import pytest +from fastcs.attributes import Attribute + +from fastcs_eiger.eiger_controller import IGNORED_KEYS, MISSING_KEYS, EigerController + +HERE = Path(__file__).parent + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "sim_eiger_controller", [str(HERE / "eiger.yaml")], indirect=True +) +async def test_controller_groups_and_parameters(sim_eiger_controller: EigerController): + controller = sim_eiger_controller + await controller.initialise() + + for subsystem in MISSING_KEYS: + subcontroller = controller.get_sub_controllers()[subsystem.title()] + parameters = await subcontroller._introspect_detector_subsystem() + if subsystem == "detector": + # ignored keys should not get added to the controller + assert all(param.key not in IGNORED_KEYS for param in parameters) + + # threshold parameters should belong to own group + for attr_name in dir(subcontroller): + attr = getattr(subcontroller, attr_name) + if isinstance(attr, Attribute) and "threshold" in attr_name: + if attr_name == "threshold_energy": + continue + assert attr.group and "Threshold" in attr.group + for keys in MISSING_KEYS[subsystem].values(): # loop over status, config keys + for key in keys: + assert any(param.key == key for param in parameters) + + await controller.connection.close() diff --git a/tests/test_controller.py b/tests/test_controller.py index 8d91e0c..f2ccfdd 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,121 +1,20 @@ import asyncio -from collections.abc import Awaitable, Callable -from typing import Any from unittest import mock import pytest -from fastcs.attributes import Attribute from pytest_mock import MockerFixture -from fastcs_eiger.eiger_controller import ( - IGNORED_KEYS, - MISSING_KEYS, - EigerConfigHandler, - EigerController, - EigerDetectorController, - EigerHandler, - EigerMonitorController, - EigerStreamController, -) +from fastcs_eiger.eiger_controller import EigerController, EigerHandler _lock = asyncio.Lock() -Getter = Callable[[str], Awaitable[dict[str, Any] | list[str]]] -Putter = Callable[[str, Any], Awaitable[list[str]]] - - -@pytest.fixture -def dummy_getter(keys_mapping: dict[str, list[str]]) -> Getter: - # if not in mapping, get dummy parameter dict - async def _getter(uri: str): - return keys_mapping.get( - uri, {"access_mode": "rw", "value": 0.0, "value_type": "float"} - ) - - return _getter - - -@pytest.fixture -def dummy_putter(put_response_mapping: dict[str, list[str]]) -> Putter: - async def _putter(uri: str, _: Any): - key = uri.split("/", 4)[-1] - # return [key] if not in mapping - return put_response_mapping.get(key, [key]) - - return _putter - - -@pytest.mark.asyncio -async def test_detector_controller( - dummy_getter: Getter, - detector_config_keys: list[str], - detector_status_keys: list[str], -): - connection = mock.Mock() - connection.get = dummy_getter - - detector_controller = EigerDetectorController(connection, _lock) - parameters = await detector_controller._introspect_detector_subsystem() - assert all(parameter.key not in IGNORED_KEYS for parameter in parameters) - for parameter in parameters: - assert parameter.key not in IGNORED_KEYS - if parameter.mode == "config": - assert ( - parameter.key in detector_config_keys - or parameter.key in MISSING_KEYS["detector"]["config"] - ) - elif parameter.mode == "status": - assert ( - parameter.key in detector_status_keys - or parameter.key in MISSING_KEYS["detector"]["status"] - ) - - # test queue_update side effect - assert not detector_controller.stale_parameters.get() - await detector_controller.queue_update(["chi_start"]) - assert detector_controller._parameter_updates == {"chi_start"} - assert detector_controller.stale_parameters.get() - - -@pytest.mark.asyncio -async def test_monitor_controller_initialises(dummy_getter: Getter): - connection = mock.Mock() - connection.get = dummy_getter - subsystem_controller = EigerMonitorController(connection, _lock) - await subsystem_controller.initialise() - @pytest.mark.asyncio -async def test_stream_controller_initialises(dummy_getter: Getter): - connection = mock.Mock() - connection.get = dummy_getter - subsystem_controller = EigerStreamController(connection, _lock) - await subsystem_controller.initialise() - - -@pytest.mark.asyncio -async def test_detector_subsystem_controller(dummy_getter: Getter): - connection = mock.Mock() - connection.get = dummy_getter - subsystem_controller = EigerDetectorController(connection, _lock) - await subsystem_controller.initialise() - - for attr_name in dir(subsystem_controller): - attr = getattr(subsystem_controller, attr_name) - if isinstance(attr, Attribute) and "threshold" in attr_name: - if attr_name == "threshold_energy": - continue - assert attr.group and "Threshold" in attr.group - - -@pytest.mark.asyncio -async def test_eiger_controller_initialises( - mocker: MockerFixture, dummy_getter: Getter -): +async def test_eiger_controller_creates_subcontrollers(mocker: MockerFixture): eiger_controller = EigerController("127.0.0.1", 80) connection = mocker.patch.object(eiger_controller, "connection") - connection.get.side_effect = dummy_getter - + connection.get = mocker.AsyncMock() + connection.put = mocker.AsyncMock() await eiger_controller.initialise() assert list(eiger_controller.get_sub_controllers().keys()) == [ "Detector", @@ -123,102 +22,68 @@ async def test_eiger_controller_initialises( "Monitor", ] connection.get.assert_any_call("detector/api/1.8.0/status/state") - connection.get.assert_any_call("stream/api/1.8.0/status/state") - connection.get.assert_any_call("monitor/api/1.8.0/status/state") + connection.get.assert_any_call("detector/api/1.8.0/status/keys") + connection.get.assert_any_call("detector/api/1.8.0/config/keys") + connection.get.assert_any_call("monitor/api/1.8.0/status/keys") + connection.get.assert_any_call("monitor/api/1.8.0/config/keys") + connection.get.assert_any_call("stream/api/1.8.0/status/keys") + connection.get.assert_any_call("stream/api/1.8.0/config/keys") @pytest.mark.asyncio -async def test_eiger_handler_update_updates_value(keys_mapping: dict[str, list[str]]): - connection = mock.Mock() - - async def _get(uri: str) -> dict[str, Any] | list[str]: - if "state" in uri: # get 1 as value for state - return {"access_mode": "r", "value": 1, "value_type": "int"} - # if not in mapping, get dummy parameter dict - return keys_mapping.get( - uri, {"access_mode": "rw", "value": 0.0, "value_type": "float"} - ) - - connection.get = _get - subsystem_controller = EigerDetectorController(connection, _lock) - await subsystem_controller.initialise() - - assert type(subsystem_controller.state.updater) is EigerHandler - assert subsystem_controller.state.get() == 0 - - # show that value changes after update is awaited - await subsystem_controller.state.updater.update( - subsystem_controller, subsystem_controller.state - ) - assert subsystem_controller.state.get() == 1 +async def test_eiger_handler_update_updates_value(mocker: MockerFixture): + dummy_uri = "subsystem/api/1.8.0/dummy_mode/dummy_uri" + updater = EigerHandler(dummy_uri) + controller = mocker.AsyncMock() + attr = mocker.Mock() + controller.connection.get.return_value = {"value": 5} -@pytest.mark.asyncio -async def test_eiger_config_handler_put(dummy_getter: Getter, dummy_putter: Putter): - connection = mock.Mock() - connection.get.side_effect = dummy_getter - connection.put.side_effect = dummy_putter - subsystem_controller = EigerDetectorController(connection, _lock) - await subsystem_controller.initialise() - attr = subsystem_controller.threshold_1_energy - handler = attr.sender - assert isinstance(handler, EigerConfigHandler) - assert not subsystem_controller.stale_parameters.get() - await handler.put(subsystem_controller, attr, 100.0) - expected_changed_params = [ - "flatfield", - "threshold/1/energy", - "threshold/1/flatfield", - "threshold/2/flatfield", - "threshold_energy", - ] - assert subsystem_controller._parameter_updates == set(expected_changed_params) - assert subsystem_controller.stale_parameters.get() + await updater.update(controller, attr) + attr.set.assert_called_once_with(5) - # flatfields are ignored keys - subsystem_controller.threshold_energy.updater.config_update = mock.AsyncMock() - await subsystem_controller.update() - assert subsystem_controller.stale_parameters.get() - subsystem_controller.threshold_energy.updater.config_update.assert_called_once_with( - subsystem_controller, subsystem_controller.threshold_energy +@pytest.mark.asyncio +async def test_eiger_handler_put(mocker: MockerFixture): + dummy_uri = "subsystem/api/1.8.0/dummy_mode/dummy_uri" + controller = mocker.AsyncMock() + await EigerHandler(dummy_uri).put(controller, mocker.Mock(), 0.1) + controller.connection.put.assert_awaited_once_with(dummy_uri, 0.1) + controller.queue_update.assert_awaited_once_with( + controller.connection.put.return_value ) - await subsystem_controller.update() - # stale does not get set False unless there are no stale parameters at start of - # update call - assert not subsystem_controller.stale_parameters.get() - assert not subsystem_controller._parameter_updates + # if controller.connection.put returns [], + # still queue_update for the handled uri + controller.connection.put.return_value = [] + no_updated_params_uri = "susbsystem/api/1.8.0/dummy_mode/no_updated_params" + await EigerHandler(no_updated_params_uri).put(controller, mocker.Mock(), 0.1) + controller.connection.put.assert_awaited_with(no_updated_params_uri, 0.1) + controller.queue_update.assert_awaited_with(["no_updated_params"]) @pytest.mark.asyncio -async def test_stale_parameter_propagates_to_top_controller( - mocker: MockerFixture, - dummy_getter: Getter, - dummy_putter: Putter, -): +async def test_stale_parameter_propagates_to_top_controller(mocker: MockerFixture): eiger_controller = EigerController("127.0.0.1", 80) connection = mocker.patch.object(eiger_controller, "connection") - connection.get.side_effect = dummy_getter - connection.put.side_effect = dummy_putter + connection.get = mock.AsyncMock() await eiger_controller.initialise() detector_controller = eiger_controller.get_sub_controllers()["Detector"] - attr = detector_controller.threshold_energy - assert not detector_controller.stale_parameters.get() - assert not eiger_controller.stale_parameters.get() + # queueing update sets subcontroller to stale + assert detector_controller.stale_parameters.get() is False + await detector_controller.queue_update(["dummy_attribute"]) + assert detector_controller.stale_parameters.get() is True - await attr.sender.put(detector_controller, attr, 100.0) - assert detector_controller.stale_parameters.get() # top controller not stale until update called - assert not eiger_controller.stale_parameters.get() + assert eiger_controller.stale_parameters.get() is False await eiger_controller.update() - assert eiger_controller.stale_parameters.get() + assert eiger_controller.stale_parameters.get() is True + assert detector_controller.stale_parameters.get() is True - # need to update again to make detector controller update its - # stale parameter attribute + # on next update, queued updates are handled and stale is cleared await eiger_controller.update() - assert not detector_controller.stale_parameters.get() - assert not eiger_controller.stale_parameters.get() + assert detector_controller.stale_parameters.get() is False + assert eiger_controller.stale_parameters.get() is False