From 172161144a02402a51988ff934441fed1b5d4bc2 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Mon, 20 Feb 2023 10:52:13 +0000 Subject: [PATCH 1/6] Edited setup.cfg --- setup.cfg | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/setup.cfg b/setup.cfg index 05a0a68..c534385 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,7 +31,15 @@ dev = black flake8 mypy + coverage + mock + pytest + pytest-mock pytest-asyncio + pytest-cov + pytest-mypy + pytest-flake8 + pytest-black sphinx-autobuild sphinx-external-toc myst-parser @@ -72,6 +80,17 @@ extend-ignore = # allow Annotated[typ, some_func("some string")] F722, +[tool:pytest] +# Run pytest with all our checkers, and don't spam us with massive tracebacks on error +addopts = + --tb=native -vv --flake8 --black --mypy --doctest-modules --doctest-glob="*.rst" + --cov=pmacfiltercontrol --cov-report term --cov-report xml:cov.xml +asyncio_mode = strict + +[coverage:run] +# This is covered in the versiongit test suite so exclude it here +omit = */_version.py + [coverage:paths] # Tests are run from installed location, map back to the src directory source = From 5ae2b2fe09b84cdf45addc5645f22f859b145e6f Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Mon, 20 Feb 2023 14:54:24 +0000 Subject: [PATCH 2/6] Added new test files with object constructor tests --- tests/test_hdf_adapter.py | 12 ++++++++++++ tests/test_pfc_wrapper.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_zmq_adapter.py | 12 ++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 tests/test_hdf_adapter.py create mode 100644 tests/test_pfc_wrapper.py create mode 100644 tests/test_zmq_adapter.py diff --git a/tests/test_hdf_adapter.py b/tests/test_hdf_adapter.py new file mode 100644 index 0000000..cac4452 --- /dev/null +++ b/tests/test_hdf_adapter.py @@ -0,0 +1,12 @@ +import pytest + +from pmacfiltercontrol.hdfadapter import HDFAdapter + + +@pytest.fixture +def hdf_adapter() -> HDFAdapter: + return HDFAdapter() + + +def test_hdf_adapter_constructor(): + HDFAdapter() diff --git a/tests/test_pfc_wrapper.py b/tests/test_pfc_wrapper.py new file mode 100644 index 0000000..8fe884a --- /dev/null +++ b/tests/test_pfc_wrapper.py @@ -0,0 +1,39 @@ +import pytest +from mock import Mock +from mock.mock import create_autospec +from softioc import builder + +from pmacfiltercontrol.hdfadapter import HDFAdapter +from pmacfiltercontrol.pmacFilterControlWrapper import Wrapper +from pmacfiltercontrol.zmqadapter import ZeroMQAdapter + + +@pytest.fixture +def mock_zmq_adapter() -> Mock: + return create_autospec(ZeroMQAdapter) + + +@pytest.fixture +def mock_hdf_adapter() -> Mock: + return create_autospec(HDFAdapter) + + +@pytest.fixture +def pfc_wrapper() -> Wrapper: + return Wrapper() + + +def test_pfc_wrapper_constructor(builder_=builder): + Wrapper( + "127.0.0.1", + 9000, + 9001, + builder=builder_, + device_name="pytest_pfcw", + filter_set_total=2, + filters_per_set=4, + detector="BLXXI-TEST-EXCBR-01", + motors="BLXXI-TEST-FILT-01", + autosave_file_path="/tmp/tmp.txt", + hdf_file_path="/tmp/tmp.h5", + ) diff --git a/tests/test_zmq_adapter.py b/tests/test_zmq_adapter.py new file mode 100644 index 0000000..87e7d99 --- /dev/null +++ b/tests/test_zmq_adapter.py @@ -0,0 +1,12 @@ +import pytest + +from pmacfiltercontrol.zmqadapter import ZeroMQAdapter + + +@pytest.fixture +def zmq_adapter() -> ZeroMQAdapter: + return ZeroMQAdapter() + + +def test_zmq_adapter_constructor(): + ZeroMQAdapter() From 13c1e7eb7e7fd6b343b4700af1e9380d9798918b Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 21 Feb 2023 15:57:55 +0000 Subject: [PATCH 3/6] Added tests for most of zmqadapter.py Added more tests for zmqadapter.py --- tests/test_zmq_adapter.py | 299 +++++++++++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) diff --git a/tests/test_zmq_adapter.py b/tests/test_zmq_adapter.py index 87e7d99..dbc4ab4 100644 --- a/tests/test_zmq_adapter.py +++ b/tests/test_zmq_adapter.py @@ -1,12 +1,309 @@ +import asyncio + +import aiozmq import pytest +import zmq +from mock import AsyncMock, Mock from pmacfiltercontrol.zmqadapter import ZeroMQAdapter +# ------------------------------------------------- + + +# https://www.roguelynn.com/words/asyncio-testing/ +# used to patch/mock asyncio coroutines +@pytest.fixture +def create_mock_coro(mocker, monkeypatch): + def _create_mock_patch_coro(to_patch=None): + mock = mocker.Mock() + + async def _coro(*args, **kwargs): + return mock(*args, **kwargs) + + if to_patch: # <-- may not need/want to patch anything + monkeypatch.setattr(to_patch, _coro) + return mock, _coro + + return _create_mock_patch_coro + + +# ------------------------------------------------- +# Mock Queue + + +@pytest.fixture +def mock_queue(mocker, monkeypatch): + queue = mocker.AsyncMock() + monkeypatch.setattr(asyncio, "Queue", queue) + return queue.return_value + + +@pytest.fixture +def mock_queue_get(mock_queue, create_mock_coro): + mock_get, coro_get = create_mock_coro() + mock_queue.get = coro_get + return mock_get + + +@pytest.fixture +def mock_queue_put_nowait(mock_queue, create_mock_coro): + mock_put_nowait, coro_put_nowait = create_mock_coro() + mock_queue.put_nowait = coro_put_nowait + return mock_put_nowait + + +# ------------------------------------------------- +# Mock Socket + + +@pytest.fixture +def mock_socket(mocker, monkeypatch): + socket = mocker.AsyncMock() + monkeypatch.setattr(aiozmq, "ZmqStream", socket) + return socket.return_value + + +@pytest.fixture +def mock_socket_read(mock_socket, create_mock_coro): + mock_read, coro_read = create_mock_coro() + mock_socket.read = coro_read + return mock_read + + +@pytest.fixture +def mock_socket_write(mock_socket, create_mock_coro): + mock_write, coro_write = create_mock_coro() + mock_socket.write = coro_write + return mock_write + + +# ------------------------------------------------- +# Zmq Adapter + @pytest.fixture def zmq_adapter() -> ZeroMQAdapter: - return ZeroMQAdapter() + zmq_adapter = ZeroMQAdapter(zmq_type=zmq.SUB) + return zmq_adapter + + +@pytest.fixture +def zmq_adapter_dealer() -> ZeroMQAdapter: + zmq_adapter = ZeroMQAdapter(zmq_type=zmq.DEALER) + return zmq_adapter + + +# ------------------------------------------------- +# Tests def test_zmq_adapter_constructor(): ZeroMQAdapter() + + +@pytest.mark.asyncio +async def test_start_stop_stream(zmq_adapter: ZeroMQAdapter): + await zmq_adapter.start_stream() + assert zmq_adapter._socket._closing is False + + await zmq_adapter.close_stream() + # wait for socket to close + await asyncio.sleep(0.1) + assert zmq_adapter._socket._closing is True + + +def test_zmq_adapter_if_running(zmq_adapter: ZeroMQAdapter): + assert zmq_adapter.check_if_running() is False + + +def test_zmq_adapter_send_message(zmq_adapter: ZeroMQAdapter): + mock_message = AsyncMock() + + zmq_adapter._send_message_queue = Mock(asyncio.Queue) + + zmq_adapter.send_message(mock_message) + + +@pytest.mark.asyncio +async def test_zmq_adapter_get_response( + zmq_adapter: ZeroMQAdapter, mock_queue, mock_queue_get +): + zmq_adapter._recv_message_queue = mock_queue + mock_queue_get.return_value = b"test" + + resp = await zmq_adapter.get_response() + + assert resp == b"test" + + mock_queue_get.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_read_response( + zmq_adapter: ZeroMQAdapter, mock_socket, mock_socket_read +): + zmq_adapter._socket = mock_socket + + f: asyncio.Future = asyncio.Future() + f.set_result([b"test"]) + mock_socket_read.return_value = f.result() + + resp = await zmq_adapter._read_response() + assert resp == b"test" + + mock_socket_read.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_dealer_read_response( + zmq_adapter_dealer: ZeroMQAdapter, mock_socket, mock_socket_read +): + zmq_adapter_dealer._socket = mock_socket + + f: asyncio.Future = asyncio.Future() + f.set_result([b"", b"test"]) + mock_socket_read.return_value = f.result() + + resp = await zmq_adapter_dealer._read_response() + + assert resp == b"test" + + mock_socket_read.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_run_forever(zmq_adapter: ZeroMQAdapter): + zmq_adapter._process_response_queue = AsyncMock() + + await zmq_adapter.run_forever() + + # zmq_adapter._process_response_queue.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_dealer_run_forever(zmq_adapter_dealer: ZeroMQAdapter): + zmq_adapter_dealer._process_message_queue = AsyncMock() + zmq_adapter_dealer._process_response_queue = AsyncMock() + + await zmq_adapter_dealer.run_forever() + + # zmq_adapter_dealer._process_message_queue.assert_awaited_once() + # zmq_adapter_dealer._process_response_queue.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_queue( + zmq_adapter: ZeroMQAdapter, mock_queue, mock_queue_get +): + zmq_adapter._process_message = AsyncMock() + + zmq_adapter._send_message_queue = mock_queue + mock_queue_get.return_value = b"test" + + await zmq_adapter._process_message_queue() + + mock_queue_get.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_no_messsage( + zmq_adapter: ZeroMQAdapter, capsys +): + await zmq_adapter._process_message(None) + + captured = capsys.readouterr() + assert captured.out == "No message\n" + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_socket_closed( + zmq_adapter: ZeroMQAdapter, mock_socket, capsys +): + zmq_adapter._socket = mock_socket + zmq_adapter._socket._closing = True + + await zmq_adapter._process_message([b"test"]) + + captured = capsys.readouterr() + assert captured.out == "Socket closed...\n" + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_zmq_error( + zmq_adapter: ZeroMQAdapter, mock_socket, mock_socket_write, capsys +): + zmq_adapter._socket = mock_socket + zmq_adapter._socket._closing = False + + # Shouldn't need to specify this below + zmq_adapter._socket.write = mock_socket_write + mock_socket_write.side_effect = zmq.error.ZMQError(6) + + await zmq_adapter._process_message([b"test"]) + + captured = capsys.readouterr() + assert captured.out == "ZMQ Error No such device or address\n" + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_exception( + zmq_adapter: ZeroMQAdapter, mock_socket, mock_socket_write, capsys +): + zmq_adapter._socket = mock_socket + zmq_adapter._socket._closing = False + + # Shouldn't need to specify this below + zmq_adapter._socket.write = mock_socket_write + mock_socket_write.side_effect = Exception("Test Exception") + + await zmq_adapter._process_message([b"test"]) + + captured = capsys.readouterr() + assert ( + captured.out == "Error, Test Exception\n" + "Unable to write to ZMQ stream, trying again...\n" + ) + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_sub( + zmq_adapter: ZeroMQAdapter, mock_socket, mock_socket_write +): + zmq_adapter._socket = mock_socket + # Shouldn't need to specify this below + zmq_adapter._socket.write = mock_socket_write + zmq_adapter._socket._closing = False + + await zmq_adapter._process_message([b"test"]) + + mock_socket_write.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_message_dealer( + zmq_adapter_dealer: ZeroMQAdapter, mock_socket, mock_socket_write +): + zmq_adapter_dealer._socket = mock_socket + # Shouldn't need to specify this below + zmq_adapter_dealer._socket.write = mock_socket_write + zmq_adapter_dealer._socket._closing = False + + await zmq_adapter_dealer._process_message([b"test"]) + + mock_socket_write.assert_called_once() + + +@pytest.mark.asyncio +async def test_zmq_adapter_process_response_queue( + zmq_adapter: ZeroMQAdapter, mock_queue, mock_queue_put_nowait +): + zmq_adapter._read_response = AsyncMock() + zmq_adapter._read_response.return_value = b"test" + + zmq_adapter._recv_message_queue = mock_queue + # Shouldn't need to specify this below + zmq_adapter._recv_message_queue.put_nowait = mock_queue_put_nowait + + await zmq_adapter._process_response_queue() + + mock_queue_put_nowait.assert_called_once() From dab5f7dbadeeab78452bd25ed231d93c8b4b7af3 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Thu, 23 Feb 2023 16:26:31 +0000 Subject: [PATCH 4/6] Added tests for hdfadapter.py --- tests/test_hdf_adapter.py | 181 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/tests/test_hdf_adapter.py b/tests/test_hdf_adapter.py index cac4452..349560d 100644 --- a/tests/test_hdf_adapter.py +++ b/tests/test_hdf_adapter.py @@ -1,12 +1,193 @@ +from pathlib import Path +from typing import Dict, Optional, Set + +import h5py import pytest +from mock import MagicMock, Mock +from numpy import int64 from pmacfiltercontrol.hdfadapter import HDFAdapter +# ------------------------------------------------- +# H5Py + + +@pytest.fixture +def mock_hdf5_file() -> MagicMock: + file = MagicMock(spec=h5py.File) + file.__enter__.return_value = {"test": 1} + return file + + +@pytest.fixture +def mock_h5py_file(mocker, mock_hdf5_file): + # def _add_to_dict(*args): + # mock_hdf5_file.file.__enter__.append() + + mocker.patch("h5py.File", return_value=mock_hdf5_file) + return mocker + + +@pytest.fixture +def mock_dataset() -> MagicMock: + dataset = MagicMock(spec=h5py.Dataset) + + dataset.size = int64(1) + + def resize(inc: Set[Optional[int]]): + dataset.size += inc + + dataset.resize = Mock(side_effect=resize) + dataset.flush = Mock() + + return dataset + + +# ------------------------------------------------- +# HDF Adapter + @pytest.fixture def hdf_adapter() -> HDFAdapter: return HDFAdapter() +# ------------------------------------------------- +# Tests + + def test_hdf_adapter_constructor(): HDFAdapter() + + +def test_hdf_adapter_set_file_path(hdf_adapter: HDFAdapter): + hdf_adapter._check_path = Mock() + hdf_adapter._check_path.return_value = True + + hdf_adapter._set_file_path("./test_hdf5.h5") + + hdf_adapter._check_path.assert_called_once() + assert hdf_adapter.file_path == "./test_hdf5.h5" + + +def test_hdf_adapter_open_file(hdf_adapter: HDFAdapter, mock_h5py_file): + hdf_adapter._check_path = Mock() + hdf_adapter._check_path.return_value = True + hdf_adapter._setup_datasets = Mock() + + assert hdf_adapter.file_open is False + + hdf_adapter._open_file() + + assert hdf_adapter.file_open is True + + +def test_hdf_adapter_open_file_already_open( + hdf_adapter: HDFAdapter, mock_hdf5_file, capsys +): + hdf_adapter.file = mock_hdf5_file + hdf_adapter.file_path = "test_path" + + hdf_adapter._open_file() + + captured = capsys.readouterr() + assert captured.out == "* Another file is already open and being written to.\n" + + +def test_hdf_adapter_close_file(hdf_adapter: HDFAdapter, mock_hdf5_file, capsys): + hdf_adapter.file = mock_hdf5_file + hdf_adapter.file_open = True + + assert hdf_adapter.file_open is True + + hdf_adapter._close_file() + + captured = capsys.readouterr() + assert captured.out == f"* File {mock_hdf5_file} has been closed.\n" + + assert hdf_adapter.file is None + assert hdf_adapter.file_open is False + + +def test_hdf_adapter_close_file_exception( + hdf_adapter: HDFAdapter, mock_hdf5_file, capsys +): + hdf_adapter.file = "test_string_to_cause_exception" + + hdf_adapter._close_file() + + captured = capsys.readouterr() + assert captured.out == "* Failed closing file.\n\n" + + +def test_hdf_adapter_check_path_is_none(hdf_adapter: HDFAdapter, capsys): + ret = hdf_adapter._check_path(file_path=None) + + captured = capsys.readouterr() + assert captured.out == "* Please enter a valid file path.\nPath=\n" + + assert ret is False + + +def test_hdf_adapter_check_path_not_found(hdf_adapter: HDFAdapter, capsys): + ret = hdf_adapter._check_path(file_path="test_path") + + captured = capsys.readouterr() + assert captured.out == "* Path not found. Enter a valid path.\n" + + assert ret is False + + +def test_hdf_adapter_check_path_exists(hdf_adapter: HDFAdapter, capsys): + ret = hdf_adapter._check_path(file_path=str(Path(__file__))) + + captured = capsys.readouterr() + assert captured.out == "* File already exists.\n" + + assert ret is False + + +def test_hdf_adapter_check_path(hdf_adapter: HDFAdapter): + ret = hdf_adapter._check_path(file_path="./test_file") + + assert ret is True + + +def test_hdf_adapter_setup_datasets(hdf_adapter: HDFAdapter, mock_hdf5_file): + def return_dataset(key: str, *args, **kwargs) -> Dict[str, str]: + return {"test_dataset": key} + + hdf_adapter.file = mock_hdf5_file + hdf_adapter.file.swmr_mode is False + + mock_hdf5_file.create_dataset = Mock(side_effect=return_dataset) + + hdf_adapter._setup_datasets() + + assert hdf_adapter.adjustment_dset == {"test_dataset": "adjustment"} + assert hdf_adapter.attenuation_dset == {"test_dataset": "attenuation"} + assert hdf_adapter.uid_dataset == {"test_dataset": "uid"} + assert hdf_adapter.filters_moving_flag_dataset == {"test_dataset": "filters_moving"} + + assert hdf_adapter.file.swmr_mode is True + + +def test_hdf_adapter_write_to_file(hdf_adapter: HDFAdapter, mock_dataset): + hdf_adapter.adjustment_dset = mock_dataset + hdf_adapter.attenuation_dset = mock_dataset + hdf_adapter.uid_dataset = mock_dataset + hdf_adapter.filters_moving_flag_dataset = mock_dataset + + data = { + "frame_number": 1, + "adjustment": 1, + "attenuation": 1, + "uid": 1, + "filters_moving": 1, + } + + hdf_adapter._write_to_file(data=data) + + assert mock_dataset.resize.call_count == 4 + assert mock_dataset.__setitem__.call_count == 4 + assert mock_dataset.flush.call_count == 4 From 84b611fb02dfcb71bf7df9c31e20f8a6b1205834 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 24 Feb 2023 14:39:58 +0000 Subject: [PATCH 5/6] Pin pytest to version 7.1.1 or newer; Removed pytest mypy, black and flake8 forks as will be run in pre-commit once that PR is merged Made workflow run pytest on all tests files Added detector_sim and event_subscriber to coverage omittion --- .github/workflows/code.yml | 2 +- setup.cfg | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 9d46a3d..2a8a38c 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -28,4 +28,4 @@ jobs: run: | python3 -m venv venv && source venv/bin/activate && pip install --upgrade pip pip install .[dev] - PMAC_FILTER_CONTROL=${GITHUB_WORKSPACE}/prefix/bin/pmacFilterControl pytest -v tests/test_pmac_filter_control.py + PMAC_FILTER_CONTROL=${GITHUB_WORKSPACE}/prefix/bin/pmacFilterControl pytest -v tests diff --git a/setup.cfg b/setup.cfg index c534385..7b5db5a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,7 @@ install_requires = importlib_metadata aioca h5py - softioc + softioc>=4.2.0 aiozmq typer>=0.7.0 # Fix incompatibility with click>=8.1.0 | https://github.com/tiangolo/typer/issues/377 @@ -33,13 +33,10 @@ dev = mypy coverage mock - pytest + pytest>=7.1.1 pytest-mock pytest-asyncio pytest-cov - pytest-mypy - pytest-flake8 - pytest-black sphinx-autobuild sphinx-external-toc myst-parser @@ -83,13 +80,16 @@ extend-ignore = [tool:pytest] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error addopts = - --tb=native -vv --flake8 --black --mypy --doctest-modules --doctest-glob="*.rst" + --tb=native -vv --doctest-modules --doctest-glob="*.rst" --cov=pmacfiltercontrol --cov-report term --cov-report xml:cov.xml asyncio_mode = strict [coverage:run] # This is covered in the versiongit test suite so exclude it here -omit = */_version.py +omit = + */_version.py + src/pmacfiltercontrol/detector_sim.py + src/pmacfiltercontrol/event_subscriber.py [coverage:paths] # Tests are run from installed location, map back to the src directory From 7783c7fcdd887d0e07d37d33ea47199dbf865349 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Mon, 27 Feb 2023 15:16:07 +0000 Subject: [PATCH 6/6] [WIP] Added conftest.py to setup subprocess for IOC to run in to allow tests to run with same IOC (wrapper) object, along with IOC startup script to be called with this; Renamed test_pfc_wrapper to test_pfc_ioc; Added test autosave file --- tests/conftest.py | 140 ++++++++++++++++++++++++++ tests/test_asyncio_pfc_wrapper.py | 50 ++++++++++ tests/test_autosave.txt | 21 ++++ tests/test_pfc_ioc.py | 157 ++++++++++++++++++++++++++++++ tests/test_pfc_wrapper.py | 39 -------- 5 files changed, 368 insertions(+), 39 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_asyncio_pfc_wrapper.py create mode 100644 tests/test_autosave.txt create mode 100644 tests/test_pfc_ioc.py delete mode 100644 tests/test_pfc_wrapper.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0a217e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,140 @@ +import atexit +import os +import random +import string +import subprocess +import sys +from datetime import datetime + +import pytest +from epicsdbbuilder import ResetRecords + +# Must import softioc before epicsdbbuilder +from softioc.device_core import RecordLookup + +requires_cothread = pytest.mark.skipif( + sys.platform.startswith("win"), reason="Cothread doesn't work on windows" +) + +# Default length used to initialise Waveform and longString records. +# Length picked to match string record length, so we can re-use test strings. +WAVEFORM_LENGTH = 40 + +# Default timeout for many operations across testing +TIMEOUT = 10 # Seconds + +# Address for multiprocessing Listener/Client pair +ADDRESS = ("localhost", 2345) + + +def create_random_prefix(): + """Create 12-character random string, for generating unique Device Names""" + return "".join(random.choice(string.ascii_uppercase) for _ in range(12)) + + +# Can't use logging as it's not multiprocess safe, and +# alteratives are overkill +def log(*args): + print(datetime.now().strftime("%H:%M:%S"), *args) + + +class SubprocessIOC: + def __init__(self, ioc_py): + self.pv_prefix = create_random_prefix() + sim_ioc = os.path.join(os.path.dirname(__file__), ioc_py) + cmd = [sys.executable, sim_ioc, self.pv_prefix] + self.proc = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + def kill(self): + if self.proc.returncode is None: + # still running, kill it and print the output + self.proc.kill() + out, err = self.proc.communicate(timeout=TIMEOUT) + print(out.decode()) + print(err.decode()) + + +def aioca_cleanup(): + from aioca import _catools, purge_channel_caches + + # Unregister the aioca atexit handler as it conflicts with the one installed + # by cothread. If we don't do this we get a seg fault. This is not a problem + # in production as we won't mix aioca and cothread, but we do mix them in + # the tests so need to do this. + atexit.unregister(_catools._catools_atexit) + # purge the channels before the event loop goes + purge_channel_caches() + + +@pytest.fixture +def pfc_ioc(): + ioc = SubprocessIOC("test_pfc_wrapper.py") + yield ioc + ioc.kill() + aioca_cleanup() + + +# @pytest.fixture +# def asyncio_ioc_override(): +# ioc = SubprocessIOC("sim_asyncio_ioc_override.py") +# yield ioc +# ioc.kill() +# aioca_cleanup() + + +def _clear_records(): + # Remove any records created at epicsdbbuilder layer + ResetRecords() + # And at pythonSoftIoc level + # TODO: Remove this hack and use use whatever comes out of + # https://github.com/dls-controls/pythonSoftIOC/issues/56 + RecordLookup._RecordDirectory.clear() + + +@pytest.fixture(autouse=True) +def clear_records(): + """Deletes all records before and after every test""" + _clear_records() + yield + _clear_records() + + +@pytest.fixture(autouse=True) +def enable_code_coverage(): + """Ensure code coverage works as expected for `multiprocesses` tests. + As its harmless for other types of test, we always run this fixture.""" + try: + from pytest_cov.embed import cleanup_on_sigterm + except ImportError: + pass + else: + cleanup_on_sigterm() + + +def select_and_recv(conn, expected_char=None): + """Wait for the given Connection to have data to receive, and return it. + If a character is provided check its correct before returning it.""" + # Must use cothread's select if cothread is present, otherwise we'd block + # processing on all cothread processing. But we don't want to use it + # unless we have to, as importing cothread can cause issues with forking. + if "cothread" in sys.modules: + from cothread import select + + rrdy, _, _ = select([conn], [], [], TIMEOUT) + else: + # Would use select.select(), but Windows doesn't accept Pipe handles + # as selectable objects. + if conn.poll(TIMEOUT): + rrdy = True + + if rrdy: + val = conn.recv() + else: + pytest.fail("Did not receive expected char before TIMEOUT expired") + + if expected_char: + assert val == expected_char, "Expected character did not match" + + return val diff --git a/tests/test_asyncio_pfc_wrapper.py b/tests/test_asyncio_pfc_wrapper.py new file mode 100644 index 0000000..738ab94 --- /dev/null +++ b/tests/test_asyncio_pfc_wrapper.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + +# require('pygelf') +# Import the basic framework components. +from softioc import asyncio_dispatcher, builder, softioc + +from pmacfiltercontrol.pmacFilterControlWrapper import ( + Wrapper as pmacFilterControlWrapper, +) + +# A couple of identification PVs, assumes this file is the name of the IOC +device_name = "pytest_pfcw" +builder.SetDeviceName(device_name) +builder.stringIn("WHOAMI", initial_value="Test Fast Attenutator Control") +builder.stringIn("HOSTNAME", VAL=os.uname()[1]) + +filter_set_total = 2 +filters_per_set = 2 + +autosave_pos_file = f"{Path.cwd()}/tests/test_autosave.txt" + +dispatcher = asyncio_dispatcher.AsyncioDispatcher() + +wrapper = pmacFilterControlWrapper( + "127.0.0.1", + 9000, + 9001, + builder=builder, + device_name=device_name, + filter_set_total=filter_set_total, + filters_per_set=filters_per_set, + detector="BLXXI-EA-EXCBR-01", + motors="BLXXI-OP-FILT-01", + autosave_file_path=autosave_pos_file, + hdf_file_path=f"{Path.cwd()}/tests/", +) + +# dispatcher(wrapper.run_forever) + +# setup_logging(default_level=logging.DEBUG) + +# Now get the IOC started +builder.LoadDatabase() +softioc.iocInit(dispatcher) + +# wrapper.set_device_info() + +# Leave the iocsh running +softioc.interactive_ioc(globals()) diff --git a/tests/test_autosave.txt b/tests/test_autosave.txt new file mode 100644 index 0000000..80e7e15 --- /dev/null +++ b/tests/test_autosave.txt @@ -0,0 +1,21 @@ +pytest_pfcw:FILTER_SET:1:IN:1 100.0 +pytest_pfcw:FILTER_SET:1:IN:2 100.0 +pytest_pfcw:FILTER_SET:2:IN:1 100.0 +pytest_pfcw:FILTER_SET:2:IN:2 100.0 +pytest_pfcw:FILTER_SET:1:OUT:1 0.0 +pytest_pfcw:FILTER_SET:1:OUT:2 0.0 +pytest_pfcw:FILTER_SET:2:OUT:1 0.0 +pytest_pfcw:FILTER_SET:2:OUT:2 0.0 +pytest_pfcw:SHUTTER:OPEN 0.0 +pytest_pfcw:SHUTTER:CLOSED 500.0 +pytest_pfcw:HIGH:THRESHOLD:EXTREME 10.0 +pytest_pfcw:HIGH:THRESHOLD:UPPER 5.0 +pytest_pfcw:HIGH:THRESHOLD:LOWER 2.0 +pytest_pfcw:LOW:THRESHOLD:UPPER 2.0 +pytest_pfcw:LOW:THRESHOLD:LOWER 5.0 +High3 10000.0 +High2 500.0 +High1 300.0 +Low1 500.0 +Low2 10000.0 +pytest_pfcw:FILTER_SET 1 diff --git a/tests/test_pfc_ioc.py b/tests/test_pfc_ioc.py new file mode 100644 index 0000000..3df9c26 --- /dev/null +++ b/tests/test_pfc_ioc.py @@ -0,0 +1,157 @@ +import asyncio + +# from importlib import reload +# from pathlib import Path +from typing import Dict + +import pytest +from mock import Mock +from mock.mock import create_autospec + +from pmacfiltercontrol.hdfadapter import HDFAdapter + +# from pmacfiltercontrol.pmacFilterControlWrapper import Wrapper +from pmacfiltercontrol.zmqadapter import ZeroMQAdapter + +# ------------------------------------------------- + + +# https://www.roguelynn.com/words/asyncio-testing/ +# used to patch/mock asyncio coroutines +@pytest.fixture +def create_mock_coro(mocker, monkeypatch): + def _create_mock_patch_coro(to_patch=None): + mock = mocker.Mock() + + async def _coro(*args, **kwargs): + return mock(*args, **kwargs) + + if to_patch: # <-- may not need/want to patch anything + monkeypatch.setattr(to_patch, _coro) + return mock, _coro + + return _create_mock_patch_coro + + +# ------------------------------------------------- +# Mock Socket + + +@pytest.fixture +def mock_run_coroutine_threadsafe(mocker, monkeypatch): + _run_coroutine_threadsafe = mocker.Mock() + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", _run_coroutine_threadsafe) + return _run_coroutine_threadsafe.return_value + + +# ------------------------------------------------- +# Objects + + +@pytest.fixture +def mock_zmq_adapter() -> Mock: + return create_autospec(ZeroMQAdapter) + + +@pytest.fixture +def mock_hdf_adapter() -> Mock: + return create_autospec(HDFAdapter) + + +# @pytest.fixture +# def builder_fixture(): +# from softioc import builder, pythonSoftIoc + +# reload(pythonSoftIoc) +# # reload(device) +# builder = reload(builder) +# return builder + + +# @pytest.fixture +# def pfc_ioc(builder_fixture) -> Wrapper: +# test_wrapper = Wrapper( +# "127.0.0.1", +# 9000, +# 9001, +# builder=builder_fixture, +# device_name="pytest_pfcw", +# filter_set_total=2, +# filters_per_set=2, +# detector="BLXXI-TEST-EXCBR-01", +# motors="BLXXI-TEST-FILT-01", +# autosave_file_path=f"{Path.cwd()}/tests/test_autosave.txt", +# hdf_file_path=f"{Path.cwd()}/tests/", +# ) +# return test_wrapper + + +# ------------------------------------------------- + + +# def test_pfc_ioc_constructor(builder_=builder): +# # Don't want these to be called so made into Mocks +# Wrapper._generate_filter_pos_records = Mock() +# Wrapper._generate_shutter_records = Mock() +# Wrapper._generate_pixel_threshold_records = Mock() + +# test_w = Wrapper( +# "127.0.0.1", +# 9998, +# 9999, +# builder=builder_, +# device_name="test_test", +# filter_set_total=2, +# filters_per_set=2, +# detector="BLXXI-TEST-EXCBR-01", +# motors="BLXXI-TEST-FILT-01", +# autosave_file_path=f"{Path.cwd()}/tests/test_autosave.txt", +# hdf_file_path=f"{Path.cwd()}/tests/", +# ) + +# assert test_w.ip == "127.0.0.1" +# assert test_w.timeout.get() == 3 + + +@pytest.mark.asyncio +async def test_pfc_ioc_send_initial_config(pfc_ioc, mock_run_coroutine_threadsafe): + pfc_ioc.connected = True + pfc_ioc._autosave_dict = {"pytest_pfcw:FILTER_SET": 1} + + pfc_ioc._configure_param = Mock() + pfc_ioc._setup_hist_thresholds = Mock() + pfc_ioc.shutter_pos_closed.get = Mock() + pfc_ioc._set_filter_set = Mock() + + await pfc_ioc._send_initial_config() + + assert pfc_ioc.attenuation.get() == 15 + + +def test_pfc_ioc_get_autosave(pfc_ioc): + + autosave_dict: Dict[str, float] = pfc_ioc._get_autosave() + + assert autosave_dict == { + "pytest_pfcw:FILTER_SET:1:IN:1": 100.0, + "pytest_pfcw:FILTER_SET:1:IN:2": 100.0, + "pytest_pfcw:FILTER_SET:2:IN:1": 100.0, + "pytest_pfcw:FILTER_SET:2:IN:2": 100.0, + "pytest_pfcw:FILTER_SET:1:OUT:1": 0.0, + "pytest_pfcw:FILTER_SET:1:OUT:2": 0.0, + "pytest_pfcw:FILTER_SET:2:OUT:1": 0.0, + "pytest_pfcw:FILTER_SET:2:OUT:2": 0.0, + "pytest_pfcw:SHUTTER:OPEN": 0.0, + "pytest_pfcw:SHUTTER:CLOSED": 500.0, + "pytest_pfcw:HIGH:THRESHOLD:EXTREME": 10.0, + "pytest_pfcw:HIGH:THRESHOLD:UPPER": 5.0, + "pytest_pfcw:HIGH:THRESHOLD:LOWER": 2.0, + "pytest_pfcw:LOW:THRESHOLD:UPPER": 2.0, + "pytest_pfcw:LOW:THRESHOLD:LOWER": 5.0, + "High3": 10000.0, + "High2": 500.0, + "High1": 300.0, + "Low1": 500.0, + "Low2": 10000.0, + "pytest_pfcw:FILTER_SET": 1, + } diff --git a/tests/test_pfc_wrapper.py b/tests/test_pfc_wrapper.py deleted file mode 100644 index 8fe884a..0000000 --- a/tests/test_pfc_wrapper.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest -from mock import Mock -from mock.mock import create_autospec -from softioc import builder - -from pmacfiltercontrol.hdfadapter import HDFAdapter -from pmacfiltercontrol.pmacFilterControlWrapper import Wrapper -from pmacfiltercontrol.zmqadapter import ZeroMQAdapter - - -@pytest.fixture -def mock_zmq_adapter() -> Mock: - return create_autospec(ZeroMQAdapter) - - -@pytest.fixture -def mock_hdf_adapter() -> Mock: - return create_autospec(HDFAdapter) - - -@pytest.fixture -def pfc_wrapper() -> Wrapper: - return Wrapper() - - -def test_pfc_wrapper_constructor(builder_=builder): - Wrapper( - "127.0.0.1", - 9000, - 9001, - builder=builder_, - device_name="pytest_pfcw", - filter_set_total=2, - filters_per_set=4, - detector="BLXXI-TEST-EXCBR-01", - motors="BLXXI-TEST-FILT-01", - autosave_file_path="/tmp/tmp.txt", - hdf_file_path="/tmp/tmp.h5", - )