diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index a16334e0..029f43c7 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -1,7 +1,7 @@ """Test harnesses to help with writitng tests for things..""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterator, Sequence from concurrent.futures import Future from contextlib import contextmanager from typing import ( @@ -270,3 +270,37 @@ def use_dummy_url_for() -> Iterator[None]: """Use the dummy URL for function in the context variable.""" with set_url_for_context(dummy_url_for): yield + + +def manually_connect_thing_slot( + host: Thing, + slot_name: str, + target: Thing | Sequence[Thing], +) -> None: + """Manually connect a thing_slot. + + This will accept either a single `Thing` instance or a sequence + of `Thing` instances. If `Mock` instances are used, note that they + must pass an `isinstance` test, so should use the ``spec`` argument + to specify the correct class for the `~lt.thing_slot` being mocked. + Mock instances must also provide a unique ``name`` attribute. + + :param host: the `~lt.Thing` on which the slot is defined. + :param slot_name: the name of the `~lt.thing_slot`. + :param target: the `~lt.Thing` or sequence of Things it should be connected to. + If a sequence of multiple Thing are passed, their names are used to create a + mapping. + :raises KeyError: if multiple targets are specified, but they do not + have unique names. + """ + if not isinstance(target, Sequence): + names: str | Sequence[str] = target.name + things = {target.name: target} + else: + names = [t.name for t in target] + if len(set(names)) != len(names): + msg = f"Thing slot targets {names} are not uniquely named." + raise KeyError(msg) + things = {t.name: t for t in target} + slot = getattr(host.__class__, slot_name) + slot.connect(host, target=names, things=things) diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 00000000..2c8d5d1e --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,57 @@ +"""Test the `testing` module.""" + +from collections.abc import Mapping + +import pytest + +import labthings_fastapi as lt +from labthings_fastapi import testing + + +class ThingA(lt.Thing): + """A Thing subclass that connects to ThingB and ThingC.""" + + friend: "ThingB" = lt.thing_slot() + friends: "Mapping[str, ThingC]" = lt.thing_slot() + + +class ThingB(lt.Thing): + """A Thing subclass that connects to a ThingA,""" + + friend: "ThingA" = lt.thing_slot() + + +class ThingC(lt.Thing): + """A dummy Thing subclass.""" + + +@pytest.mark.parametrize("mock_slots", [True, False]) +def test_manual_connect(mock_slots, mocker): + """Make sure we can create and connect Things without a server.""" + a = testing.create_thing_without_server(ThingA, mock_all_slots=mock_slots) + b = testing.create_thing_without_server(ThingB, mock_all_slots=mock_slots) + c = testing.create_thing_without_server(ThingC, mock_all_slots=mock_slots) + + testing.manually_connect_thing_slot(a, "friend", b) + testing.manually_connect_thing_slot(a, "friends", [c]) + testing.manually_connect_thing_slot(b, "friend", a) + + assert a.friend is b + assert len(a.friends) == 1 + assert a.friends["thingc"] is c + assert b.friend is a + + mc1 = mocker.Mock(spec=ThingC) + mc1.name = "mock_c_1" + mc2 = mocker.Mock(spec=ThingC) + mc2.name = "mock_c_2" + + testing.manually_connect_thing_slot(a, "friends", [mc1, mc2]) + assert a.friends["mock_c_1"] is mc1 + assert a.friends["mock_c_2"] is mc2 + + with pytest.raises(TypeError): + testing.manually_connect_thing_slot(a, "friend", mocker.Mock()) + + with pytest.raises(KeyError, match="are not uniquely named"): + testing.manually_connect_thing_slot(a, "friend", [mc1, mc1])