|
8 | 8 | JsonObjectSpanExporter, |
9 | 9 | asserting_span_exporter, |
10 | 10 | ) |
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 | +) |
13 | 21 | from blueapi.client.event_bus import AnyEvent, BlueskyStreamingError, EventBusClient |
14 | 22 | from blueapi.client.rest import BlueapiRestClient, BlueskyRemoteControlError |
15 | 23 | from blueapi.config import MissingStompConfigurationError |
|
36 | 44 | ] |
37 | 45 | ) |
38 | 46 | 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 | +) |
39 | 60 | DEVICES = DeviceResponse( |
40 | 61 | devices=[ |
41 | 62 | DeviceModel(name="foo", protocols=[]), |
@@ -106,12 +127,20 @@ def client_with_events(mock_rest: Mock, mock_events: MagicMock): |
106 | 127 | return BlueapiClient(rest=mock_rest, events=mock_events) |
107 | 128 |
|
108 | 129 |
|
| 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 | + |
109 | 137 | def test_get_plans(client: BlueapiClient): |
110 | 138 | assert PlanResponse(plans=[p.model for p in client.plans]) == PLANS |
111 | 139 |
|
112 | 140 |
|
113 | 141 | def test_get_plan(client: BlueapiClient): |
114 | 142 | assert client.plans.foo.model == PLAN |
| 143 | + assert client.plans["foo"].model == PLAN |
115 | 144 |
|
116 | 145 |
|
117 | 146 | def test_get_nonexistant_plan( |
@@ -462,6 +491,10 @@ def callback(on_event: Callable[[AnyEvent, MessageContext], None]): |
462 | 491 | mock_on_event.assert_called_once_with(COMPLETE_EVENT) |
463 | 492 |
|
464 | 493 |
|
| 494 | +def test_get_oidc_config(client, mock_rest): |
| 495 | + assert client.oidc_config == mock_rest.get_oidc_config() |
| 496 | + |
| 497 | + |
465 | 498 | def test_get_plans_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient): |
466 | 499 | with asserting_span_exporter(exporter, "plans"): |
467 | 500 | _ = client.plans |
@@ -569,3 +602,157 @@ def test_cannot_run_task_span_ok( |
569 | 602 | ): |
570 | 603 | with asserting_span_exporter(exporter, "grun_task"): |
571 | 604 | 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