From e21f785bfd0f7117534870dc8d35a7856a4bad2a Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Fri, 13 Dec 2024 11:14:32 +0000 Subject: [PATCH] Gave each set of backend tests it's own `AssertableController` This is needed since not all datatypes are supported on every backend. A --- src/fastcs/transport/epics/util.py | 14 +- tests/__init__.py | 0 tests/assertable_controller.py | 111 ++++++++++++++++ tests/conftest.py | 163 ++++-------------------- tests/test_launch.py | 2 +- tests/transport/epics/test_gui.py | 11 +- tests/transport/epics/test_ioc.py | 30 ++++- tests/transport/graphQL/test_graphQL.py | 68 ++++++---- tests/transport/rest/test_rest.py | 49 +++++-- tests/transport/tango/test_dsr.py | 35 ++++- 10 files changed, 297 insertions(+), 186 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/assertable_controller.py diff --git a/src/fastcs/transport/epics/util.py b/src/fastcs/transport/epics/util.py index 52653342..de79b551 100644 --- a/src/fastcs/transport/epics/util.py +++ b/src/fastcs/transport/epics/util.py @@ -39,7 +39,6 @@ "max_alarm": "HOPR", "znam": "ZNAM", "onam": "ONAM", - "shape": "length", } @@ -50,12 +49,23 @@ def get_record_metadata_from_attribute( def get_record_metadata_from_datatype(datatype: DataType[T]) -> dict[str, str]: - return { + arguments = { DATATYPE_FIELD_TO_RECORD_FIELD[field]: value for field, value in asdict(datatype).items() if field in DATATYPE_FIELD_TO_RECORD_FIELD } + match datatype: + case WaveForm(): + if len(datatype.shape) != 1: + raise TypeError( + f"Unsupported shape {datatype.shape}, the EPICS backend only " + "supports to 1D arrays" + ) + arguments["length"] = datatype.shape[0] + + return arguments + def get_cast_method_to_epics_type(datatype: DataType[T]) -> Callable[[T], object]: match datatype: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py new file mode 100644 index 00000000..9000e70f --- /dev/null +++ b/tests/assertable_controller.py @@ -0,0 +1,111 @@ +import copy +from contextlib import contextmanager +from typing import Literal + +from pytest_mock import MockerFixture + +from fastcs.attributes import AttrR, Handler, Sender, Updater +from fastcs.controller import Controller, SubController +from fastcs.datatypes import Int +from fastcs.wrappers import command, scan + + +class TestUpdater(Updater): + update_period = 1 + + async def update(self, controller, attr): + print(f"{controller} update {attr}") + + +class TestSender(Sender): + async def put(self, controller, attr, value): + print(f"{controller}: {attr} = {value}") + + +class TestHandler(Handler, TestUpdater, TestSender): + pass + + +class TestSubController(SubController): + read_int: AttrR = AttrR(Int(), handler=TestUpdater()) + + +class TestController(Controller): + def __init__(self) -> None: + super().__init__() + + self._sub_controllers: list[TestSubController] = [] + for index in range(1, 3): + controller = TestSubController() + self._sub_controllers.append(controller) + self.register_sub_controller(f"SubController{index:02d}", controller) + + initialised = False + connected = False + count = 0 + + async def initialise(self) -> None: + self.initialised = True + + async def connect(self) -> None: + self.connected = True + + @command() + async def go(self): + pass + + @scan(0.01) + async def counter(self): + self.count += 1 + + +class AssertableController(TestController): + def __init__(self, mocker: MockerFixture) -> None: + self.mocker = mocker + super().__init__() + + @contextmanager + def assert_read_here(self, path: list[str]): + yield from self._assert_method(path, "get") + + @contextmanager + def assert_write_here(self, path: list[str]): + yield from self._assert_method(path, "process") + + @contextmanager + def assert_execute_here(self, path: list[str]): + yield from self._assert_method(path, "") + + def _assert_method(self, path: list[str], method: Literal["get", "process", ""]): + """ + This context manager can be used to confirm that a fastcs + controller's respective attribute or command methods are called + a single time within a context block + """ + queue = copy.deepcopy(path) + + # Navigate to subcontroller + controller = self + item_name = queue.pop(-1) + for item in queue: + controllers = controller.get_sub_controllers() + controller = controllers[item] + + # create probe + if method: + attr = getattr(controller, item_name) + spy = self.mocker.spy(attr, method) + else: + spy = self.mocker.spy(controller, item_name) + initial = spy.call_count + + try: + yield # Enter context + except Exception as e: + raise e + else: # Exit context + final = spy.call_count + assert final == initial + 1, ( + f"Expected {'.'.join(path + [method] if method else path)} " + f"to be called once, but it was called {final - initial} times." + ) diff --git a/tests/conftest.py b/tests/conftest.py index f7ff20ab..01e45838 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,45 @@ -import copy -import enum import os import random import string import subprocess import time -from contextlib import contextmanager from pathlib import Path -from typing import Any, Literal +from typing import Any -import numpy as np import pytest from aioca import purge_channel_caches -from pytest_mock import MockerFixture -from fastcs.attributes import AttrR, AttrRW, AttrW, Handler, Sender, Updater -from fastcs.controller import Controller, SubController -from fastcs.datatypes import Bool, Enum, Float, Int, String, WaveForm -from fastcs.wrappers import command, scan +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, Float, Int, String +from tests.assertable_controller import ( + TestController, + TestHandler, + TestSender, + TestUpdater, +) DATA_PATH = Path(__file__).parent / "data" +class BackendTestController(TestController): + read_int: AttrR = AttrR(Int(), handler=TestUpdater()) + read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler()) + read_write_float: AttrRW = AttrRW(Float()) + read_bool: AttrR = AttrR(Bool()) + write_bool: AttrW = AttrW(Bool(), handler=TestSender()) + read_string: AttrRW = AttrRW(String()) + big_enum: AttrR = AttrR( + Int( + allowed_values=list(range(17)), + ), + ) + + +@pytest.fixture +def controller(): + return BackendTestController() + + @pytest.fixture def data() -> Path: return DATA_PATH @@ -45,131 +63,6 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): raise excinfo.value -class TestUpdater(Updater): - update_period = 1 - - async def update(self, controller, attr): - print(f"{controller} update {attr}") - - -class TestSender(Sender): - async def put(self, controller, attr, value): - print(f"{controller}: {attr} = {value}") - - -class TestHandler(Handler, TestUpdater, TestSender): - pass - - -class TestSubController(SubController): - read_int: AttrR = AttrR(Int(), handler=TestUpdater()) - - -class TestController(Controller): - def __init__(self) -> None: - super().__init__() - - self._sub_controllers: list[TestSubController] = [] - for index in range(1, 3): - controller = TestSubController() - self._sub_controllers.append(controller) - self.register_sub_controller(f"SubController{index:02d}", controller) - - read_int: AttrR = AttrR(Int(), handler=TestUpdater()) - read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler()) - read_write_float: AttrRW = AttrRW(Float()) - read_bool: AttrR = AttrR(Bool()) - write_bool: AttrW = AttrW(Bool(), handler=TestSender()) - read_string: AttrRW = AttrRW(String()) - enum: AttrRW = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) - one_d_waveform: AttrRW = AttrRW(WaveForm(np.int32, (10,))) - two_d_waveform: AttrRW = AttrRW(WaveForm(np.int32, (10, 10))) - big_enum: AttrR = AttrR( - Int( - allowed_values=list(range(17)), - ), - ) - - initialised = False - connected = False - count = 0 - - async def initialise(self) -> None: - self.initialised = True - - async def connect(self) -> None: - self.connected = True - - @command() - async def go(self): - pass - - @scan(0.01) - async def counter(self): - self.count += 1 - - -class AssertableController(TestController): - def __init__(self, mocker: MockerFixture) -> None: - super().__init__() - self.mocker = mocker - - @contextmanager - def assert_read_here(self, path: list[str]): - yield from self._assert_method(path, "get") - - @contextmanager - def assert_write_here(self, path: list[str]): - yield from self._assert_method(path, "process") - - @contextmanager - def assert_execute_here(self, path: list[str]): - yield from self._assert_method(path, "") - - def _assert_method(self, path: list[str], method: Literal["get", "process", ""]): - """ - This context manager can be used to confirm that a fastcs - controller's respective attribute or command methods are called - a single time within a context block - """ - queue = copy.deepcopy(path) - - # Navigate to subcontroller - controller = self - item_name = queue.pop(-1) - for item in queue: - controllers = controller.get_sub_controllers() - controller = controllers[item] - - # create probe - if method: - attr = getattr(controller, item_name) - spy = self.mocker.spy(attr, method) - else: - spy = self.mocker.spy(controller, item_name) - initial = spy.call_count - try: - yield # Enter context - except Exception as e: - raise e - else: # Exit context - final = spy.call_count - assert final == initial + 1, ( - f"Expected {'.'.join(path + [method] if method else path)} " - f"to be called once, but it was called {final - initial} times." - ) - - -@pytest.fixture -def controller(): - return TestController() - - -@pytest.fixture(scope="class") -def assertable_controller(class_mocker: MockerFixture): - return AssertableController(class_mocker) - - PV_PREFIX = "".join(random.choice(string.ascii_lowercase) for _ in range(12)) HERE = Path(os.path.dirname(os.path.abspath(__file__))) diff --git a/tests/test_launch.py b/tests/test_launch.py index cafae874..61b516fc 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -92,7 +92,7 @@ def test_over_defined_schema(): error = ( "" "Expected no more than 2 arguments for 'ManyArgs.__init__' " - "but received 3 as `(self, arg: test_launch.SomeConfig, too_many)`" + "but received 3 as `(self, arg: tests.test_launch.SomeConfig, too_many)`" ) with pytest.raises(LaunchError) as exc_info: diff --git a/tests/transport/epics/test_gui.py b/tests/transport/epics/test_gui.py index 64a5004a..71db7cb8 100644 --- a/tests/transport/epics/test_gui.py +++ b/tests/transport/epics/test_gui.py @@ -1,14 +1,12 @@ from pvi.device import ( LED, ButtonPanel, - ComboBox, Group, SignalR, SignalRW, SignalW, SignalX, SubScreen, - TextFormat, TextRead, TextWrite, ToggleButton, @@ -47,19 +45,12 @@ def test_get_components(controller): children=[ SignalR( name="ReadInt", - read_pv="DEVICE:SubController01:ReadInt", + read_pv="DEVICE:SubController02:ReadInt", read_widget=TextRead(), ) ], ), SignalR(name="BigEnum", read_pv="DEVICE:BigEnum", read_widget=TextRead()), - SignalRW( - name="Enum", - read_pv="DEVICE:Enum_RBV", - read_widget=TextRead(format=TextFormat.string), - write_pv="DEVICE:Enum", - write_widget=ComboBox(choices=["RED", "GREEN", "BLUE"]), - ), SignalR(name="ReadBool", read_pv="DEVICE:ReadBool", read_widget=LED()), SignalR( name="ReadInt", diff --git a/tests/transport/epics/test_ioc.py b/tests/transport/epics/test_ioc.py index e4ddfa8c..78fd9aae 100644 --- a/tests/transport/epics/test_ioc.py +++ b/tests/transport/epics/test_ioc.py @@ -4,11 +4,17 @@ import numpy as np import pytest from pytest_mock import MockerFixture +from tests.assertable_controller import ( + AssertableController, + TestHandler, + TestSender, + TestUpdater, +) from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import Controller from fastcs.cs_methods import Command -from fastcs.datatypes import Enum, Int, String, WaveForm +from fastcs.datatypes import Bool, Enum, Float, Int, String, WaveForm from fastcs.exceptions import FastCSException from fastcs.transport.epics.ioc import ( EPICS_MAX_NAME_LENGTH, @@ -91,7 +97,6 @@ class ColourEnum(enum.IntEnum): {"ZRST": "DISABLED", "ONST": "ENABLED"}, ), (AttrR(WaveForm(np.int32, (10,))), "WaveformIn", {}), - (AttrR(WaveForm(np.int32, (10, 10))), "WaveformIn", {}), ), ) def test_get_input_record( @@ -189,6 +194,27 @@ def test_get_output_record_raises(mocker: MockerFixture): _get_output_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock()) +class EpicsAssertableController(AssertableController): + read_int = AttrR(Int(), handler=TestUpdater()) + read_write_int = AttrRW(Int(), handler=TestHandler()) + read_write_float = AttrRW(Float()) + read_bool = AttrR(Bool()) + write_bool = AttrW(Bool(), handler=TestSender()) + read_string = AttrRW(String()) + enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) + one_d_waveform = AttrRW(WaveForm(np.int32, (10,))) + big_enum = AttrR( + Int( + allowed_values=list(range(17)), + ), + ) + + +@pytest.fixture() +def controller(class_mocker: MockerFixture): + return EpicsAssertableController(class_mocker) + + def test_ioc(mocker: MockerFixture, controller: Controller): builder = mocker.patch("fastcs.transport.epics.ioc.builder") add_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_pvi_info") diff --git a/tests/transport/graphQL/test_graphQL.py b/tests/transport/graphQL/test_graphQL.py index 05ba00e0..0f61dd7c 100644 --- a/tests/transport/graphQL/test_graphQL.py +++ b/tests/transport/graphQL/test_graphQL.py @@ -4,10 +4,38 @@ import pytest from fastapi.testclient import TestClient - +from pytest_mock import MockerFixture +from tests.assertable_controller import ( + AssertableController, + TestHandler, + TestSender, + TestUpdater, +) + +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, Float, Int, String from fastcs.transport.graphQL.adapter import GraphQLTransport +class RestAssertableController(AssertableController): + read_int = AttrR(Int(), handler=TestUpdater()) + read_write_int = AttrRW(Int(), handler=TestHandler()) + read_write_float = AttrRW(Float()) + read_bool = AttrR(Bool()) + write_bool = AttrW(Bool(), handler=TestSender()) + read_string = AttrRW(String()) + big_enum = AttrR( + Int( + allowed_values=list(range(17)), + ), + ) + + +@pytest.fixture(scope="class") +def assertable_controller(class_mocker: MockerFixture): + return RestAssertableController(class_mocker) + + def nest_query(path: list[str]) -> str: queue = copy.deepcopy(path) field = queue.pop(0) @@ -44,10 +72,12 @@ def nest_responce(path: list[str], value: Any) -> dict: class TestGraphQLServer: @pytest.fixture(scope="class") def client(self, assertable_controller): - app = GraphQLTransport(assertable_controller)._server._app + app = GraphQLTransport( + assertable_controller, + )._server._app return TestClient(app) - def test_read_int(self, assertable_controller, client): + def test_read_int(self, client, assertable_controller): expect = 0 path = ["readInt"] query = f"query {{ {nest_query(path)} }}" @@ -56,7 +86,7 @@ def test_read_int(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, expect) - def test_read_write_int(self, assertable_controller, client): + def test_read_write_int(self, client, assertable_controller): expect = 0 path = ["readWriteInt"] query = f"query {{ {nest_query(path)} }}" @@ -72,7 +102,7 @@ def test_read_write_int(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, new) - def test_read_write_float(self, assertable_controller, client): + def test_read_write_float(self, client, assertable_controller): expect = 0 path = ["readWriteFloat"] query = f"query {{ {nest_query(path)} }}" @@ -88,7 +118,7 @@ def test_read_write_float(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, new) - def test_read_bool(self, assertable_controller, client): + def test_read_bool(self, client, assertable_controller): expect = False path = ["readBool"] query = f"query {{ {nest_query(path)} }}" @@ -97,7 +127,7 @@ def test_read_bool(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, expect) - def test_write_bool(self, assertable_controller, client): + def test_write_bool(self, client, assertable_controller): value = True path = ["writeBool"] mutation = f"mutation {{ {nest_mutation(path, value)} }}" @@ -106,23 +136,7 @@ def test_write_bool(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, value) - def test_string_enum(self, assertable_controller, client): - expect = "" - path = ["stringEnum"] - query = f"query {{ {nest_query(path)} }}" - with assertable_controller.assert_read_here(["string_enum"]): - response = client.post("/graphql", json={"query": query}) - assert response.status_code == 200 - assert response.json()["data"] == nest_responce(path, expect) - - new = "new" - mutation = f"mutation {{ {nest_mutation(path, new)} }}" - with assertable_controller.assert_write_here(["string_enum"]): - response = client.post("/graphql", json={"query": mutation}) - assert response.status_code == 200 - assert response.json()["data"] == nest_responce(path, new) - - def test_big_enum(self, assertable_controller, client): + def test_big_enum(self, client, assertable_controller): expect = 0 path = ["bigEnum"] query = f"query {{ {nest_query(path)} }}" @@ -131,7 +145,7 @@ def test_big_enum(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, expect) - def test_go(self, assertable_controller, client): + def test_go(self, client, assertable_controller): path = ["go"] mutation = f"mutation {{ {nest_query(path)} }}" with assertable_controller.assert_execute_here(path): @@ -139,7 +153,7 @@ def test_go(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == {path[-1]: True} - def test_read_child1(self, assertable_controller, client): + def test_read_child1(self, client, assertable_controller): expect = 0 path = ["SubController01", "readInt"] query = f"query {{ {nest_query(path)} }}" @@ -148,7 +162,7 @@ def test_read_child1(self, assertable_controller, client): assert response.status_code == 200 assert response.json()["data"] == nest_responce(path, expect) - def test_read_child2(self, assertable_controller, client): + def test_read_child2(self, client, assertable_controller): expect = 0 path = ["SubController02", "readInt"] query = f"query {{ {nest_query(path)} }}" diff --git a/tests/transport/rest/test_rest.py b/tests/transport/rest/test_rest.py index 27f9811e..e237b1eb 100644 --- a/tests/transport/rest/test_rest.py +++ b/tests/transport/rest/test_rest.py @@ -1,10 +1,43 @@ +import enum + import numpy as np import pytest from fastapi.testclient import TestClient - +from pytest_mock import MockerFixture +from tests.assertable_controller import ( + AssertableController, + TestHandler, + TestSender, + TestUpdater, +) + +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, Enum, Float, Int, String, WaveForm from fastcs.transport.rest.adapter import RestTransport +class RestAssertableController(AssertableController): + read_int = AttrR(Int(), handler=TestUpdater()) + read_write_int = AttrRW(Int(), handler=TestHandler()) + read_write_float = AttrRW(Float()) + read_bool = AttrR(Bool()) + write_bool = AttrW(Bool(), handler=TestSender()) + read_string = AttrRW(String()) + enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) + one_d_waveform = AttrRW(WaveForm(np.int32, (10,))) + two_d_waveform = AttrRW(WaveForm(np.int32, (10, 10))) + big_enum = AttrR( + Int( + allowed_values=list(range(17)), + ), + ) + + +@pytest.fixture(scope="class") +def assertable_controller(class_mocker: MockerFixture): + return RestAssertableController(class_mocker) + + class TestRestServer: @pytest.fixture(scope="class") def client(self, assertable_controller): @@ -12,13 +45,6 @@ def client(self, assertable_controller): with TestClient(app) as client: yield client - def test_read_int(self, assertable_controller, client): - expect = 0 - with assertable_controller.assert_read_here(["read_int"]): - response = client.get("/read-int") - assert response.status_code == 200 - assert response.json()["value"] == expect - def test_read_write_int(self, assertable_controller, client): expect = 0 with assertable_controller.assert_read_here(["read_write_int"]): @@ -30,6 +56,13 @@ def test_read_write_int(self, assertable_controller, client): response = client.put("/read-write-int", json={"value": new}) assert client.get("/read-write-int").json()["value"] == new + def test_read_int(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["read_int"]): + response = client.get("/read-int") + assert response.status_code == 200 + assert response.json()["value"] == expect + def test_read_write_float(self, assertable_controller, client): expect = 0 with assertable_controller.assert_read_here(["read_write_float"]): diff --git a/tests/transport/tango/test_dsr.py b/tests/transport/tango/test_dsr.py index 6fbf7108..b4beed27 100644 --- a/tests/transport/tango/test_dsr.py +++ b/tests/transport/tango/test_dsr.py @@ -1,11 +1,44 @@ +import enum + import numpy as np import pytest +from pytest_mock import MockerFixture from tango import DevState from tango.test_context import DeviceTestContext - +from tests.assertable_controller import ( + AssertableController, + TestHandler, + TestSender, + TestUpdater, +) + +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes import Bool, Enum, Float, Int, String, WaveForm from fastcs.transport.tango.adapter import TangoTransport +class TangoAssertableController(AssertableController): + read_int = AttrR(Int(), handler=TestUpdater()) + read_write_int = AttrRW(Int(), handler=TestHandler()) + read_write_float = AttrRW(Float()) + read_bool = AttrR(Bool()) + write_bool = AttrW(Bool(), handler=TestSender()) + read_string = AttrRW(String()) + enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) + one_d_waveform = AttrRW(WaveForm(np.int32, (10,))) + two_d_waveform = AttrRW(WaveForm(np.int32, (10, 10))) + big_enum = AttrR( + Int( + allowed_values=list(range(17)), + ), + ) + + +@pytest.fixture(scope="class") +def assertable_controller(class_mocker: MockerFixture): + return TangoAssertableController(class_mocker) + + class TestTangoContext: @pytest.fixture(scope="class") def tango_context(self, assertable_controller):