Skip to content

Commit cf99485

Browse files
committed
Up the coverage
1 parent 38f5bb3 commit cf99485

2 files changed

Lines changed: 191 additions & 4 deletions

File tree

src/blueapi/client/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ def help_text(self) -> str:
142142

143143
@property
144144
def properties(self) -> set[str]:
145-
return self.model.parameter_schema["properties"]
145+
return self.model.parameter_schema.get("properties", {}).keys()
146146

147147
@property
148148
def required(self) -> list[str]:
149-
return self.model.parameter_schema["required"]
149+
return self.model.parameter_schema.get("required", [])
150150

151151
def _build_args(self, *args, **kwargs):
152152
log.info(

tests/unit_tests/client/test_client.py

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
JsonObjectSpanExporter,
99
asserting_span_exporter,
1010
)
11-
12-
from blueapi.client.client import BlueapiClient
11+
from pydantic import HttpUrl
12+
13+
from blueapi.client.client import (
14+
BlueapiClient,
15+
DeviceCache,
16+
DeviceRef,
17+
MissingInstrumentSessionError,
18+
Plan,
19+
PlanCache,
20+
)
1321
from blueapi.client.event_bus import AnyEvent, BlueskyStreamingError, EventBusClient
1422
from blueapi.client.rest import BlueapiRestClient, BlueskyRemoteControlError
1523
from blueapi.config import MissingStompConfigurationError
@@ -36,6 +44,19 @@
3644
]
3745
)
3846
PLAN = PlanModel(name="foo")
47+
FULL_PLAN = PlanModel(
48+
name="foobar",
49+
description="Description of plan foobar",
50+
schema={
51+
"title": "foobar",
52+
"description": "Model description of plan foobar",
53+
"properties": {
54+
"one": {},
55+
"two": {},
56+
},
57+
"required": ["one"],
58+
},
59+
)
3960
DEVICES = DeviceResponse(
4061
devices=[
4162
DeviceModel(name="foo", protocols=[]),
@@ -106,12 +127,20 @@ def client_with_events(mock_rest: Mock, mock_events: MagicMock):
106127
return BlueapiClient(rest=mock_rest, events=mock_events)
107128

108129

130+
def test_client_from_config():
131+
bc = BlueapiClient.from_config_file(
132+
"tests/unit_tests/valid_example_config/client.yaml"
133+
)
134+
assert bc._rest._config.url == HttpUrl("http://example.com:8082")
135+
136+
109137
def test_get_plans(client: BlueapiClient):
110138
assert PlanResponse(plans=[p.model for p in client.plans]) == PLANS
111139

112140

113141
def test_get_plan(client: BlueapiClient):
114142
assert client.plans.foo.model == PLAN
143+
assert client.plans["foo"].model == PLAN
115144

116145

117146
def test_get_nonexistant_plan(
@@ -462,6 +491,10 @@ def callback(on_event: Callable[[AnyEvent, MessageContext], None]):
462491
mock_on_event.assert_called_once_with(COMPLETE_EVENT)
463492

464493

494+
def test_get_oidc_config(client, mock_rest):
495+
assert client.oidc_config == mock_rest.get_oidc_config()
496+
497+
465498
def test_get_plans_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient):
466499
with asserting_span_exporter(exporter, "plans"):
467500
_ = client.plans
@@ -569,3 +602,157 @@ def test_cannot_run_task_span_ok(
569602
):
570603
with asserting_span_exporter(exporter, "grun_task"):
571604
client.run_task(TaskRequest(name="foo", instrument_session="cm12345-1"))
605+
606+
607+
def test_instrument_session_required(client):
608+
with pytest.raises(MissingInstrumentSessionError):
609+
_ = client.instrument_session
610+
611+
612+
def test_setting_instrument_session(client):
613+
# This looks like a completely pointless test but instrument_session is a
614+
# property with some logic so it's not purely to get coverage up
615+
client.instrument_session = "cm12345-4"
616+
assert client.instrument_session == "cm12345-4"
617+
618+
619+
def test_plan_cache_ignores_underscores(client):
620+
cache = PlanCache(client, [PlanModel(name="_ignored"), PlanModel(name="used")])
621+
with pytest.raises(AttributeError, match="_ignored"):
622+
_ = cache._ignored
623+
624+
625+
def test_device_cache_ignores_underscores():
626+
rest = Mock()
627+
rest.get_devices.return_value = DeviceResponse(
628+
devices=[
629+
DeviceModel(name="_ignored", protocols=[]),
630+
]
631+
)
632+
cache = DeviceCache(rest)
633+
with pytest.raises(AttributeError, match="_ignored"):
634+
_ = cache._ignored
635+
636+
rest.get_devices.reset_mock()
637+
with pytest.raises(AttributeError, match="_anything"):
638+
_ = cache._anything
639+
rest.get_device.assert_not_called()
640+
641+
642+
def test_devices_are_cached(mock_rest):
643+
cache = DeviceCache(mock_rest)
644+
_ = cache.foo
645+
mock_rest.get_device.assert_not_called()
646+
_ = cache["foo"]
647+
mock_rest.get_device.assert_not_called()
648+
649+
650+
def test_device_repr():
651+
cache = Mock()
652+
model = Mock()
653+
dev = DeviceRef(name="foo", cache=cache, model=model)
654+
assert repr(dev) == "Device(foo)"
655+
656+
657+
def test_device_ignores_underscores():
658+
cache = MagicMock()
659+
model = Mock()
660+
dev = DeviceRef(name="foo", cache=cache, model=model)
661+
with pytest.raises(AttributeError, match="_underscore"):
662+
_ = dev._underscore
663+
cache.__getitem__.assert_not_called()
664+
665+
666+
def test_plan_help_text(client):
667+
plan = Plan("foo", PlanModel(name="foo", description="help for foo"), client)
668+
assert plan.help_text == "help for foo"
669+
670+
671+
def test_plan_fallback_help_text(client):
672+
plan = Plan(
673+
"foo",
674+
PlanModel(
675+
name="foo",
676+
schema={"properties": {"one": {}, "two": {}}, "required": ["one"]},
677+
),
678+
client,
679+
)
680+
assert plan.help_text == "Plan foo(one, two=None)"
681+
682+
683+
def test_plan_properties(client):
684+
plan = Plan(
685+
"foo",
686+
PlanModel(
687+
name="foo",
688+
schema={"properties": {"one": {}, "two": {}}, "required": ["one"]},
689+
),
690+
client,
691+
)
692+
693+
assert plan.properties == {"one", "two"}
694+
assert plan.required == ["one"]
695+
696+
697+
def test_plan_empty_fallback_help_text(client):
698+
plan = Plan(
699+
"foo", PlanModel(name="foo", schema={"properties": {}, "required": []}), client
700+
)
701+
assert plan.help_text == "Plan foo()"
702+
703+
704+
p = pytest.param
705+
706+
707+
@pytest.mark.parametrize(
708+
"args,kwargs,params",
709+
[
710+
p((1,), {}, {"one": 1}, id="required_as_positional"),
711+
p((), {"one": 7}, {"one": 7}, id="required_as_keyword"),
712+
p((1,), {"two": 23}, {"one": 1, "two": 23}, id="all_as_mixed_args_kwargs"),
713+
p((1, 2), {}, {"one": 1, "two": 2}, id="all_as_positional"),
714+
p((), {"one": 21, "two": 42}, {"one": 21, "two": 42}, id="all_as_keyword"),
715+
],
716+
)
717+
def test_plan_param_mapping(args, kwargs, params):
718+
client = Mock()
719+
client.instrument_session = "cm12345-1"
720+
plan = Plan(
721+
FULL_PLAN.name,
722+
FULL_PLAN,
723+
client,
724+
)
725+
726+
plan(*args, **kwargs)
727+
client.run_task.assert_called_once_with(
728+
TaskRequest(name="foobar", instrument_session="cm12345-1", params=params)
729+
)
730+
731+
732+
@pytest.mark.parametrize(
733+
"args,kwargs,msg",
734+
[
735+
p((), {}, r"Missing argument\(s\) for \{'one'\}", id="missing_required"),
736+
p((1,), {"one": 7}, "multiple values for one", id="duplicate_required"),
737+
p((1, 2), {"two": 23}, "multiple values for two", id="duplicate_optional"),
738+
p((1, 2, 3), {}, "too many arguments", id="too_many_args"),
739+
p(
740+
(),
741+
{"unknown_key": 42},
742+
r"got unexpected arguments: \{'unknown_key'\}",
743+
id="unknown_arg",
744+
),
745+
],
746+
)
747+
def test_plan_invalid_param_mapping(args, kwargs, msg):
748+
client = Mock()
749+
client.instrument_session = "cm12345-1"
750+
plan = Plan(
751+
FULL_PLAN.name,
752+
FULL_PLAN,
753+
client,
754+
)
755+
756+
with pytest.raises(TypeError, match=msg):
757+
plan(*args, **kwargs)
758+
client.run_task.assert_not_called()

0 commit comments

Comments
 (0)