diff --git a/docs/how-to/write-tests-for-devices.rst b/docs/how-to/write-tests-for-devices.rst index c1e5ca1c43..35169ff5c4 100644 --- a/docs/how-to/write-tests-for-devices.rst +++ b/docs/how-to/write-tests-for-devices.rst @@ -35,6 +35,8 @@ Sim Utility Functions Sim signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_sim_value``, to mock-set values for sim signals, including read-only ones. +In addition this example also utilizes helper functions like ``assert_reading`` and ``assert_value`` to ensure the validity of device readings and values. For more information see: :doc:`API.core<../generated/ophyd_async.core>` + .. literalinclude:: ../../tests/epics/demo/test_demo.py :pyobject: test_sensor_reading_shows_value @@ -43,3 +45,12 @@ There is another utility function, ``set_sim_callback``, for hooking in logic wh .. literalinclude:: ../../tests/epics/demo/test_demo.py :pyobject: test_mover_stopped + + +Testing a Device in a Plan with the RunEngine +--------------------------------------------- +.. literalinclude:: ../../tests/epics/demo/test_demo.py + :pyobject: test_sensor_in_plan + + +This test verifies that the sim_sensor behaves as expected within a plan. The plan we use here is a ``count``, which takes a specified number of readings from the ``sim_sensor``. Since we set the ``repeat`` to two in this test, the sensor should emit two "event" documents along with "start", "stop" and "descriptor" documents. Finally, we use the helper function ``assert_emitted`` to confirm that the emitted documents match our expectations. diff --git a/src/ophyd_async/core/__init__.py b/src/ophyd_async/core/__init__.py index 22a76c2e81..8d0493801c 100644 --- a/src/ophyd_async/core/__init__.py +++ b/src/ophyd_async/core/__init__.py @@ -30,6 +30,10 @@ SignalRW, SignalW, SignalX, + assert_configuration, + assert_emitted, + assert_reading, + assert_value, observe_value, set_and_wait_for_value, set_sim_callback, @@ -105,4 +109,8 @@ "walk_rw_signals", "load_device", "save_device", + "assert_reading", + "assert_value", + "assert_configuration", + "assert_emitted", ] diff --git a/src/ophyd_async/core/signal.py b/src/ophyd_async/core/signal.py index cdf8f45ee0..5c31293291 100644 --- a/src/ophyd_async/core/signal.py +++ b/src/ophyd_async/core/signal.py @@ -2,7 +2,18 @@ import asyncio import functools -from typing import AsyncGenerator, Callable, Dict, Generic, Optional, Tuple, Type, Union +from typing import ( + Any, + AsyncGenerator, + Callable, + Dict, + Generic, + Mapping, + Optional, + Tuple, + Type, + Union, +) from bluesky.protocols import ( Descriptor, @@ -13,7 +24,7 @@ Subscribable, ) -from ophyd_async.protocols import AsyncReadable, AsyncStageable +from ophyd_async.protocols import AsyncConfigurable, AsyncReadable, AsyncStageable from .async_status import AsyncStatus from .device import Device @@ -271,6 +282,91 @@ def soft_signal_r_and_backend( return (signal, backend) +async def assert_value(signal: SignalR[T], value: Any) -> None: + """Assert a signal's value and compare it an expected signal. + + Parameters + ---------- + signal: + signal with get_value. + value: + The expected value from the signal. + + Notes + ----- + Example usage:: + await assert_value(signal, value) + + """ + assert await signal.get_value() == value + + +async def assert_reading( + readable: AsyncReadable, reading: Mapping[str, Reading] +) -> None: + """Assert readings from readable. + + Parameters + ---------- + readable: + Callable with readable.read function that generate readings. + + reading: + The expected readings from the readable. + + Notes + ----- + Example usage:: + await assert_reading(readable, reading) + + """ + assert await readable.read() == reading + + +async def assert_configuration( + configurable: AsyncConfigurable, + configuration: Mapping[str, Reading], +) -> None: + """Assert readings from Configurable. + + Parameters + ---------- + configurable: + Configurable with Configurable.read function that generate readings. + + configuration: + The expected readings from configurable. + + Notes + ----- + Example usage:: + await assert_configuration(configurable configuration) + + """ + assert await configurable.read_configuration() == configuration + + +def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int): + """Assert emitted document generated by running a Bluesky plan + + Parameters + ---------- + Doc: + A dictionary + + numbers: + expected emission in kwarg from + + Notes + ----- + Example usage:: + assert_emitted(docs, start=1, descriptor=1, + resource=1, datum=1, event=1, stop=1) + """ + assert list(docs) == list(numbers) + assert {name: len(d) for name, d in docs.items()} == numbers + + async def observe_value(signal: SignalR[T], timeout=None) -> AsyncGenerator[T, None]: """Subscribe to the value of a signal so it can be iterated from. diff --git a/tests/core/test_signal.py b/tests/core/test_signal.py index f2aa4252be..5b3037f7b7 100644 --- a/tests/core/test_signal.py +++ b/tests/core/test_signal.py @@ -1,15 +1,24 @@ import asyncio import re import time +from unittest.mock import ANY import numpy import pytest +from bluesky.protocols import Reading from ophyd_async.core import ( + ConfigSignal, + DeviceCollector, + HintedSignal, Signal, SignalR, SignalRW, SimSignalBackend, + StandardReadable, + assert_configuration, + assert_reading, + assert_value, set_and_wait_for_value, set_sim_put_proceeds, set_sim_value, @@ -18,6 +27,7 @@ wait_for_value, ) from ophyd_async.core.utils import DEFAULT_TIMEOUT +from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw class MySignal(Signal): @@ -153,3 +163,68 @@ async def test_soft_signal_numpy(): await int_signal.connect() assert (await float_signal.describe())["float_signal"]["dtype"] == "number" assert (await int_signal.describe())["int_signal"]["dtype"] == "integer" + + +@pytest.fixture +async def sim_signal(): + sim_signal = SignalRW(SimSignalBackend(int, "test")) + sim_signal.set_name("sim_signal") + await sim_signal.connect(sim=True) + yield sim_signal + + +async def test_assert_value(sim_signal: SignalRW): + set_sim_value(sim_signal, 168) + await assert_value(sim_signal, 168) + + +async def test_assert_reaading(sim_signal: SignalRW): + set_sim_value(sim_signal, 888) + dummy_reading = { + "sim_signal": Reading({"alarm_severity": 0, "timestamp": ANY, "value": 888}) + } + await assert_reading(sim_signal, dummy_reading) + + +class DummyReadable(StandardReadable): + """A demo Readable to produce read and config signal""" + + def __init__(self, prefix: str, name="") -> None: + # Define some signals + with self.add_children_as_readables(HintedSignal): + self.value = epics_signal_r(float, prefix + "Value") + with self.add_children_as_readables(ConfigSignal): + self.mode = epics_signal_rw(str, prefix + "Mode") + self.mode2 = epics_signal_rw(str, prefix + "Mode2") + # Set name and signals for read() and read_configuration() + super().__init__(name=name) + + +@pytest.fixture +async def sim_readable(): + async with DeviceCollector(sim=True): + sim_readable = DummyReadable("SIM:READABLE:") + # Signals connected here + assert sim_readable.name == "sim_readable" + yield sim_readable + + +async def test_assert_configuration(sim_readable: DummyReadable): + set_sim_value(sim_readable.value, 123) + set_sim_value(sim_readable.mode, "super mode") + set_sim_value(sim_readable.mode2, "slow mode") + dummy_config_reading = { + "sim_readable-mode": ( + { + "alarm_severity": 0, + "timestamp": ANY, + "value": "super mode", + } + ), + "sim_readable-mode2": { + "alarm_severity": 0, + "timestamp": ANY, + "value": "slow mode", + }, + } + await assert_configuration(sim_readable, dummy_config_reading) diff --git a/tests/epics/demo/test_demo.py b/tests/epics/demo/test_demo.py index ba4f941497..35ff880191 100644 --- a/tests/epics/demo/test_demo.py +++ b/tests/epics/demo/test_demo.py @@ -1,14 +1,20 @@ import asyncio import subprocess +from collections import defaultdict from typing import Dict from unittest.mock import ANY, Mock, call, patch import pytest +from bluesky import plans as bp from bluesky.protocols import Reading +from bluesky.run_engine import RunEngine from ophyd_async.core import ( DeviceCollector, NotConnected, + assert_emitted, + assert_reading, + assert_value, set_sim_callback, set_sim_value, ) @@ -84,7 +90,8 @@ async def test_mover_moving_well(sim_mover: demo.Mover) -> None: precision=3, time_elapsed=pytest.approx(0.0, abs=0.05), ) - assert 0.55 == await sim_mover.setpoint.get_value() + + await assert_value(sim_mover.setpoint, 0.55) assert not s.done done.assert_not_called() await asyncio.sleep(0.1) @@ -110,24 +117,30 @@ async def test_mover_moving_well(sim_mover: demo.Mover) -> None: async def test_sensor_reading_shows_value(sim_sensor: demo.Sensor): # Check default value + await assert_value(sim_sensor.value, pytest.approx(0.0)) assert (await sim_sensor.value.get_value()) == pytest.approx(0.0) - assert (await sim_sensor.read()) == { - "sim_sensor-value": { - "alarm_severity": 0, - "timestamp": ANY, - "value": 0.0, - } - } - + await assert_reading( + sim_sensor, + { + "sim_sensor-value": { + "value": 0.0, + "alarm_severity": 0, + "timestamp": ANY, + } + }, + ) # Check different value set_sim_value(sim_sensor.value, 5.0) - assert (await sim_sensor.read()) == { - "sim_sensor-value": { - "alarm_severity": 0, - "timestamp": ANY, - "value": 5.0, - } - } + await assert_reading( + sim_sensor, + { + "sim_sensor-value": { + "value": 5.0, + "timestamp": ANY, + "alarm_severity": 0, + } + }, + ) async def test_mover_stopped(sim_mover: demo.Mover): @@ -202,6 +215,21 @@ async def test_read_sensor(sim_sensor: demo.Sensor): await sim_sensor.unstage() +async def test_sensor_in_plan(RE: RunEngine, sim_sensor: demo.Sensor): + """Tests sim sensor behavior within a RunEngine plan. + + This test verifies that the sensor emits the expected documents + when used in plan(count). + """ + docs = defaultdict(list) + + def capture_emitted(name, doc): + docs[name].append(doc) + + RE(bp.count([sim_sensor], num=2), capture_emitted) + assert_emitted(docs, start=1, descriptor=1, event=2, stop=1) + + async def test_assembly_renaming() -> None: thing = demo.SampleStage("PRE") await thing.connect(sim=True) @@ -245,9 +273,28 @@ async def test_dynamic_sensor_group_read_and_describe( await sim_sensor_group.stage() description = await sim_sensor_group.describe() - reading = await sim_sensor_group.read() - await sim_sensor_group.unstage() + await sim_sensor_group.unstage() + await assert_reading( + sim_sensor_group, + { + "sim_sensor_group-sensors-1-value": { + "value": 0.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + "sim_sensor_group-sensors-2-value": { + "value": 0.5, + "timestamp": ANY, + "alarm_severity": 0, + }, + "sim_sensor_group-sensors-3-value": { + "value": 1.0, + "timestamp": ANY, + "alarm_severity": 0, + }, + }, + ) assert description == { "sim_sensor_group-sensors-1-value": { "dtype": "number", @@ -265,23 +312,6 @@ async def test_dynamic_sensor_group_read_and_describe( "source": "soft://sim_sensor_group-sensors-3-value", }, } - assert reading == { - "sim_sensor_group-sensors-1-value": { - "alarm_severity": 0, - "timestamp": ANY, - "value": 0.0, - }, - "sim_sensor_group-sensors-2-value": { - "alarm_severity": 0, - "timestamp": ANY, - "value": 0.5, - }, - "sim_sensor_group-sensors-3-value": { - "alarm_severity": 0, - "timestamp": ANY, - "value": 1.0, - }, - } @patch("ophyd_async.epics.demo.subprocess.Popen") diff --git a/tests/panda/test_hdf_panda.py b/tests/panda/test_hdf_panda.py index 2c376fb12f..092340594b 100644 --- a/tests/panda/test_hdf_panda.py +++ b/tests/panda/test_hdf_panda.py @@ -10,7 +10,7 @@ from ophyd_async.core.detector import DetectorControl, DetectorTrigger from ophyd_async.core.device import Device from ophyd_async.core.flyer import HardwareTriggeredFlyable -from ophyd_async.core.signal import SignalR, wait_for_value +from ophyd_async.core.signal import SignalR, assert_emitted, wait_for_value from ophyd_async.core.sim_signal_backend import SimSignalBackend from ophyd_async.core.utils import DEFAULT_TIMEOUT from ophyd_async.panda import HDFPanda, PcapBlock @@ -21,11 +21,6 @@ ) -def assert_emitted(docs: Dict[str, list], **numbers: int): - assert list(docs) == list(numbers) - assert {name: len(d) for name, d in docs.items()} == numbers - - class MockPandaPcapController(DetectorControl): def __init__(self, pcap: PcapBlock) -> None: self.pcap = pcap diff --git a/tests/sim/test_streaming_plan.py b/tests/sim/test_streaming_plan.py index a077bcc919..ac44051224 100644 --- a/tests/sim/test_streaming_plan.py +++ b/tests/sim/test_streaming_plan.py @@ -1,17 +1,12 @@ from collections import defaultdict -from typing import Dict from bluesky import plans as bp from bluesky.run_engine import RunEngine +from ophyd_async.core.signal import assert_emitted from ophyd_async.sim.sim_pattern_generator import SimPatternDetector -def assert_emitted(docs: Dict[str, list], **numbers: int): - assert list(docs) == list(numbers) - assert {name: len(d) for name, d in docs.items()} == numbers - - # NOTE the async operations with h5py are non-trival # because of lack of native support for async operations # see https://github.com/h5py/h5py/issues/837