ucapi-framework 1.8.3__tar.gz → 1.9.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/PKG-INFO +1 -1
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/pyproject.toml +1 -1
- ucapi_framework-1.9.0b1/tests/test_button_entity.py +154 -0
- ucapi_framework-1.9.0b1/tests/test_climate_entity.py +230 -0
- ucapi_framework-1.9.0b1/tests/test_cover_entity.py +228 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_driver.py +123 -3
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_entity.py +105 -1
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_helpers.py +13 -5
- ucapi_framework-1.9.0b1/tests/test_ir_emitter_entity.py +120 -0
- ucapi_framework-1.9.0b1/tests/test_light_entity.py +234 -0
- ucapi_framework-1.9.0b1/tests/test_media_player_entity.py +259 -0
- ucapi_framework-1.9.0b1/tests/test_remote_entity.py +125 -0
- ucapi_framework-1.9.0b1/tests/test_select_entity.py +195 -0
- ucapi_framework-1.9.0b1/tests/test_sensor_entity.py +206 -0
- ucapi_framework-1.9.0b1/tests/test_switch_entity.py +128 -0
- ucapi_framework-1.9.0b1/tests/test_voice_assistant_entity.py +128 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/__init__.py +25 -1
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/driver.py +189 -44
- ucapi_framework-1.9.0b1/ucapi_framework/entities/__init__.py +35 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/button.py +89 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/climate.py +236 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/cover.py +161 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/ir_emitter.py +89 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/light.py +201 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/media_player.py +488 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/remote.py +95 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/select.py +165 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/sensor.py +155 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/switch.py +99 -0
- ucapi_framework-1.9.0b1/ucapi_framework/entities/voice_assistant.py +93 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/entity.py +94 -1
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/helpers.py +1 -1
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework.egg-info/PKG-INFO +1 -1
- ucapi_framework-1.9.0b1/ucapi_framework.egg-info/SOURCES.txt +48 -0
- ucapi_framework-1.8.3/ucapi_framework.egg-info/SOURCES.txt +0 -25
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/README.md +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/setup.cfg +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_config.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_device.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_discovery.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_factory_pattern.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_migration.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/tests/test_setup.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/config.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/device.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/discovery.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/migration.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework/setup.py +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework.egg-info/dependency_links.txt +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework.egg-info/requires.txt +0 -0
- {ucapi_framework-1.8.3 → ucapi_framework-1.9.0b1}/ucapi_framework.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Tests for ButtonEntity with built-in state management."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
from ucapi import button
|
|
6
|
+
from ucapi_framework import ButtonEntity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestButtonEntity:
|
|
10
|
+
"""Test ButtonEntity state management."""
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_api(self):
|
|
14
|
+
"""Create a mock API for testing."""
|
|
15
|
+
api = MagicMock()
|
|
16
|
+
api.configured_entities.get.return_value = MagicMock(
|
|
17
|
+
attributes={button.Attributes.STATE: button.States.AVAILABLE}
|
|
18
|
+
)
|
|
19
|
+
return api
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def entity(self, mock_api):
|
|
23
|
+
"""Create a ButtonEntity for testing."""
|
|
24
|
+
entity = ButtonEntity(
|
|
25
|
+
"button.test",
|
|
26
|
+
"Test Button",
|
|
27
|
+
)
|
|
28
|
+
entity._api = mock_api # noqa: SLF001
|
|
29
|
+
return entity
|
|
30
|
+
|
|
31
|
+
def test_initial_state(self, entity):
|
|
32
|
+
"""Test initial state from constructor attributes."""
|
|
33
|
+
assert entity.state == button.States.AVAILABLE
|
|
34
|
+
|
|
35
|
+
def test_initial_state_always_available(self, mock_api):
|
|
36
|
+
"""Test that button always initializes STATE to AVAILABLE (ucapi hardcodes this)."""
|
|
37
|
+
entity = ButtonEntity(
|
|
38
|
+
"button.unset",
|
|
39
|
+
"Unset Button",
|
|
40
|
+
)
|
|
41
|
+
entity._api = mock_api # noqa: SLF001
|
|
42
|
+
# ucapi.button.Button hardcodes {Attributes.STATE: States.AVAILABLE} in __init__
|
|
43
|
+
assert entity.state == button.States.AVAILABLE
|
|
44
|
+
|
|
45
|
+
def test_set_state_with_update(self, entity, mock_api):
|
|
46
|
+
"""Test set_state() calls entity.update() by default."""
|
|
47
|
+
entity.set_state(button.States.UNAVAILABLE, update=True)
|
|
48
|
+
|
|
49
|
+
# Verify internal state was updated
|
|
50
|
+
assert entity.state == button.States.UNAVAILABLE
|
|
51
|
+
|
|
52
|
+
# Verify update was called
|
|
53
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
54
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
55
|
+
entity_id, attributes = call_args[0]
|
|
56
|
+
assert entity_id == "button.test"
|
|
57
|
+
assert button.Attributes.STATE in attributes
|
|
58
|
+
assert attributes[button.Attributes.STATE] == button.States.UNAVAILABLE
|
|
59
|
+
|
|
60
|
+
def test_set_state_without_update(self, entity, mock_api):
|
|
61
|
+
"""Test set_state(update=False) does not call entity.update()."""
|
|
62
|
+
entity.set_state(button.States.UNAVAILABLE, update=False)
|
|
63
|
+
|
|
64
|
+
# Verify internal state was updated
|
|
65
|
+
assert entity.state == button.States.UNAVAILABLE
|
|
66
|
+
|
|
67
|
+
# Verify update was NOT called
|
|
68
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
69
|
+
|
|
70
|
+
def test_set_state_available(self, entity, mock_api):
|
|
71
|
+
"""Test transitioning back to AVAILABLE."""
|
|
72
|
+
entity.set_state(button.States.UNAVAILABLE, update=False)
|
|
73
|
+
assert entity.state == button.States.UNAVAILABLE
|
|
74
|
+
|
|
75
|
+
entity.set_state(button.States.AVAILABLE, update=False)
|
|
76
|
+
assert entity.state == button.States.AVAILABLE
|
|
77
|
+
|
|
78
|
+
def test_set_state_none_does_not_update_remote(self, entity, mock_api):
|
|
79
|
+
"""Test set_state(None) stores None but update filter strips it."""
|
|
80
|
+
# set_state(None) writes None into attributes dict
|
|
81
|
+
entity.set_state(None, update=True)
|
|
82
|
+
|
|
83
|
+
# Internal state stores None
|
|
84
|
+
assert entity.state is None
|
|
85
|
+
|
|
86
|
+
# The update should have been called but the None filter in
|
|
87
|
+
# update_attributes strips the None value — so update_attributes
|
|
88
|
+
# is called with an empty dict (or not called if filter removes all)
|
|
89
|
+
# Either way, the Remote should not receive a None value
|
|
90
|
+
if mock_api.configured_entities.update_attributes.called:
|
|
91
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
92
|
+
_, attributes = call_args[0]
|
|
93
|
+
assert button.Attributes.STATE not in attributes
|
|
94
|
+
|
|
95
|
+
def test_property_getter_is_read_only(self, entity):
|
|
96
|
+
"""Test that the state property cannot be set directly."""
|
|
97
|
+
with pytest.raises(AttributeError):
|
|
98
|
+
entity.state = button.States.AVAILABLE # type: ignore[misc]
|
|
99
|
+
|
|
100
|
+
def test_only_one_attribute(self, entity, mock_api):
|
|
101
|
+
"""Test that Button only has the STATE attribute."""
|
|
102
|
+
entity.set_state(button.States.UNAVAILABLE, update=True)
|
|
103
|
+
|
|
104
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
105
|
+
_, attributes = call_args[0]
|
|
106
|
+
# Should only contain STATE
|
|
107
|
+
assert list(attributes.keys()) == [button.Attributes.STATE]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestButtonEntityInheritance:
|
|
111
|
+
"""Test that ButtonEntity can be subclassed and overridden."""
|
|
112
|
+
|
|
113
|
+
def test_custom_set_state(self):
|
|
114
|
+
"""Test that set_state can be overridden."""
|
|
115
|
+
|
|
116
|
+
class CustomButton(ButtonEntity):
|
|
117
|
+
def __init__(self):
|
|
118
|
+
super().__init__(
|
|
119
|
+
"button.custom",
|
|
120
|
+
"Custom Button",
|
|
121
|
+
)
|
|
122
|
+
self.custom_set_state_called = False
|
|
123
|
+
|
|
124
|
+
def set_state(self, value, *, update=True):
|
|
125
|
+
"""Override set_state to add custom logic."""
|
|
126
|
+
self.custom_set_state_called = True
|
|
127
|
+
super().set_state(value, update=update)
|
|
128
|
+
|
|
129
|
+
entity = CustomButton()
|
|
130
|
+
entity._api = MagicMock() # noqa: SLF001
|
|
131
|
+
entity._api.configured_entities.get.return_value = entity # noqa: SLF001
|
|
132
|
+
|
|
133
|
+
entity.set_state(button.States.AVAILABLE, update=False)
|
|
134
|
+
assert entity.custom_set_state_called is True
|
|
135
|
+
assert entity.state == button.States.AVAILABLE
|
|
136
|
+
|
|
137
|
+
def test_custom_property_getter(self):
|
|
138
|
+
"""Test that the state property getter can be overridden."""
|
|
139
|
+
|
|
140
|
+
class CustomButton(ButtonEntity):
|
|
141
|
+
def __init__(self):
|
|
142
|
+
super().__init__(
|
|
143
|
+
"button.custom",
|
|
144
|
+
"Custom Button",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def state(self):
|
|
149
|
+
"""Override state getter to always return AVAILABLE."""
|
|
150
|
+
return button.States.AVAILABLE
|
|
151
|
+
|
|
152
|
+
entity = CustomButton()
|
|
153
|
+
# Property override always returns AVAILABLE
|
|
154
|
+
assert entity.state == button.States.AVAILABLE
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Tests for ClimateEntity with built-in state management."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
from ucapi import climate
|
|
6
|
+
from ucapi_framework import ClimateEntity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestClimateEntity:
|
|
10
|
+
"""Test ClimateEntity state management."""
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_api(self):
|
|
14
|
+
"""Create a mock API for testing."""
|
|
15
|
+
api = MagicMock()
|
|
16
|
+
api.configured_entities.get.return_value = MagicMock(
|
|
17
|
+
attributes={climate.Attributes.STATE: climate.States.OFF}
|
|
18
|
+
)
|
|
19
|
+
return api
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def entity(self, mock_api):
|
|
23
|
+
"""Create a ClimateEntity for testing."""
|
|
24
|
+
entity = ClimateEntity(
|
|
25
|
+
"climate.test",
|
|
26
|
+
"Test Thermostat",
|
|
27
|
+
features=[climate.Features.ON_OFF, climate.Features.HEAT],
|
|
28
|
+
attributes={climate.Attributes.STATE: climate.States.OFF},
|
|
29
|
+
)
|
|
30
|
+
entity._api = mock_api # noqa: SLF001
|
|
31
|
+
return entity
|
|
32
|
+
|
|
33
|
+
def test_initial_state(self, entity):
|
|
34
|
+
"""Test initial state from constructor attributes."""
|
|
35
|
+
assert entity.state == climate.States.OFF
|
|
36
|
+
assert entity.current_temperature is None
|
|
37
|
+
assert entity.target_temperature is None
|
|
38
|
+
assert entity.target_temperature_high is None
|
|
39
|
+
assert entity.target_temperature_low is None
|
|
40
|
+
assert entity.fan_mode is None
|
|
41
|
+
|
|
42
|
+
def test_set_state_with_update(self, entity, mock_api):
|
|
43
|
+
"""Test set_state() calls entity.update() by default."""
|
|
44
|
+
entity.set_state(climate.States.HEAT, update=True)
|
|
45
|
+
|
|
46
|
+
assert entity.state == climate.States.HEAT
|
|
47
|
+
|
|
48
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
49
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
50
|
+
entity_id, attributes = call_args[0]
|
|
51
|
+
assert entity_id == "climate.test"
|
|
52
|
+
assert climate.Attributes.STATE in attributes
|
|
53
|
+
assert attributes[climate.Attributes.STATE] == climate.States.HEAT
|
|
54
|
+
|
|
55
|
+
def test_set_state_without_update(self, entity, mock_api):
|
|
56
|
+
"""Test set_state(update=False) does not call entity.update()."""
|
|
57
|
+
entity.set_state(climate.States.COOL, update=False)
|
|
58
|
+
|
|
59
|
+
assert entity.state == climate.States.COOL
|
|
60
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
61
|
+
|
|
62
|
+
def test_set_current_temperature(self, entity, mock_api):
|
|
63
|
+
"""Test set_current_temperature() updates state and calls update."""
|
|
64
|
+
entity.set_current_temperature(22.5, update=True)
|
|
65
|
+
|
|
66
|
+
assert entity.current_temperature == 22.5
|
|
67
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
68
|
+
|
|
69
|
+
def test_set_current_temperature_without_update(self, entity, mock_api):
|
|
70
|
+
"""Test set_current_temperature(update=False) does not call update."""
|
|
71
|
+
entity.set_current_temperature(22.5, update=False)
|
|
72
|
+
|
|
73
|
+
assert entity.current_temperature == 22.5
|
|
74
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
75
|
+
|
|
76
|
+
def test_set_target_temperature(self, entity, mock_api):
|
|
77
|
+
"""Test set_target_temperature() updates state and calls update."""
|
|
78
|
+
entity.set_target_temperature(21.0, update=True)
|
|
79
|
+
|
|
80
|
+
assert entity.target_temperature == 21.0
|
|
81
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
82
|
+
|
|
83
|
+
def test_set_target_temperature_without_update(self, entity, mock_api):
|
|
84
|
+
"""Test set_target_temperature(update=False) does not call update."""
|
|
85
|
+
entity.set_target_temperature(21.0, update=False)
|
|
86
|
+
|
|
87
|
+
assert entity.target_temperature == 21.0
|
|
88
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
89
|
+
|
|
90
|
+
def test_set_target_temperature_high(self, entity, mock_api):
|
|
91
|
+
"""Test set_target_temperature_high() updates state and calls update."""
|
|
92
|
+
entity.set_target_temperature_high(25.0, update=True)
|
|
93
|
+
|
|
94
|
+
assert entity.target_temperature_high == 25.0
|
|
95
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
96
|
+
|
|
97
|
+
def test_set_target_temperature_low(self, entity, mock_api):
|
|
98
|
+
"""Test set_target_temperature_low() updates state and calls update."""
|
|
99
|
+
entity.set_target_temperature_low(18.0, update=True)
|
|
100
|
+
|
|
101
|
+
assert entity.target_temperature_low == 18.0
|
|
102
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
103
|
+
|
|
104
|
+
def test_set_fan_mode(self, entity, mock_api):
|
|
105
|
+
"""Test set_fan_mode() updates state and calls update."""
|
|
106
|
+
entity.set_fan_mode("AUTO", update=True)
|
|
107
|
+
|
|
108
|
+
assert entity.fan_mode == "AUTO"
|
|
109
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
110
|
+
|
|
111
|
+
def test_set_fan_mode_without_update(self, entity, mock_api):
|
|
112
|
+
"""Test set_fan_mode(update=False) does not call update."""
|
|
113
|
+
entity.set_fan_mode("HIGH", update=False)
|
|
114
|
+
|
|
115
|
+
assert entity.fan_mode == "HIGH"
|
|
116
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
117
|
+
|
|
118
|
+
def test_set_attributes_bulk_update(self, entity, mock_api):
|
|
119
|
+
"""Test set_attributes() updates multiple attributes with single update call."""
|
|
120
|
+
entity.set_attributes(
|
|
121
|
+
state=climate.States.HEAT,
|
|
122
|
+
current_temperature=20.5,
|
|
123
|
+
target_temperature=22.0,
|
|
124
|
+
fan_mode="AUTO",
|
|
125
|
+
update=True,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert entity.state == climate.States.HEAT
|
|
129
|
+
assert entity.current_temperature == 20.5
|
|
130
|
+
assert entity.target_temperature == 22.0
|
|
131
|
+
assert entity.fan_mode == "AUTO"
|
|
132
|
+
|
|
133
|
+
# Verify update was called only once
|
|
134
|
+
assert mock_api.configured_entities.update_attributes.call_count == 1
|
|
135
|
+
|
|
136
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
137
|
+
entity_id, attributes = call_args[0]
|
|
138
|
+
assert entity_id == "climate.test"
|
|
139
|
+
assert len(attributes) == 4
|
|
140
|
+
assert attributes[climate.Attributes.STATE] == climate.States.HEAT
|
|
141
|
+
assert attributes[climate.Attributes.CURRENT_TEMPERATURE] == 20.5
|
|
142
|
+
assert attributes[climate.Attributes.TARGET_TEMPERATURE] == 22.0
|
|
143
|
+
assert attributes[climate.Attributes.FAN_MODE] == "AUTO"
|
|
144
|
+
|
|
145
|
+
def test_set_attributes_without_update(self, entity, mock_api):
|
|
146
|
+
"""Test set_attributes(update=False) does not call entity.update()."""
|
|
147
|
+
entity.set_attributes(
|
|
148
|
+
state=climate.States.COOL,
|
|
149
|
+
target_temperature=19.0,
|
|
150
|
+
update=False,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert entity.state == climate.States.COOL
|
|
154
|
+
assert entity.target_temperature == 19.0
|
|
155
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
156
|
+
|
|
157
|
+
def test_set_attributes_ignores_none_values(self, entity, mock_api):
|
|
158
|
+
"""Test set_attributes() ignores None values."""
|
|
159
|
+
entity.set_attributes(
|
|
160
|
+
state=climate.States.HEAT, current_temperature=None, update=True
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
assert entity.state == climate.States.HEAT
|
|
164
|
+
assert entity.current_temperature is None
|
|
165
|
+
|
|
166
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
167
|
+
entity_id, attributes = call_args[0]
|
|
168
|
+
assert len(attributes) == 1
|
|
169
|
+
assert climate.Attributes.STATE in attributes
|
|
170
|
+
|
|
171
|
+
def test_set_attributes_all_six(self, entity, mock_api):
|
|
172
|
+
"""Test setting all six climate attributes at once."""
|
|
173
|
+
entity.set_attributes(
|
|
174
|
+
state=climate.States.HEAT_COOL,
|
|
175
|
+
current_temperature=21.0,
|
|
176
|
+
target_temperature=22.0,
|
|
177
|
+
target_temperature_high=25.0,
|
|
178
|
+
target_temperature_low=18.0,
|
|
179
|
+
fan_mode="AUTO",
|
|
180
|
+
update=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert entity.state == climate.States.HEAT_COOL
|
|
184
|
+
assert entity.current_temperature == 21.0
|
|
185
|
+
assert entity.target_temperature == 22.0
|
|
186
|
+
assert entity.target_temperature_high == 25.0
|
|
187
|
+
assert entity.target_temperature_low == 18.0
|
|
188
|
+
assert entity.fan_mode == "AUTO"
|
|
189
|
+
|
|
190
|
+
assert mock_api.configured_entities.update_attributes.call_count == 1
|
|
191
|
+
|
|
192
|
+
def test_property_getters_are_read_only(self, entity):
|
|
193
|
+
"""Test that property getters cannot be set directly."""
|
|
194
|
+
with pytest.raises(AttributeError):
|
|
195
|
+
entity.state = climate.States.HEAT # type: ignore[misc]
|
|
196
|
+
|
|
197
|
+
with pytest.raises(AttributeError):
|
|
198
|
+
entity.current_temperature = 20.0 # type: ignore[misc]
|
|
199
|
+
|
|
200
|
+
with pytest.raises(AttributeError):
|
|
201
|
+
entity.target_temperature = 21.0 # type: ignore[misc]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestClimateEntityInheritance:
|
|
205
|
+
"""Test that ClimateEntity can be subclassed and overridden."""
|
|
206
|
+
|
|
207
|
+
def test_custom_set_state(self):
|
|
208
|
+
"""Test that set_state can be overridden."""
|
|
209
|
+
|
|
210
|
+
class CustomClimate(ClimateEntity):
|
|
211
|
+
def __init__(self):
|
|
212
|
+
super().__init__(
|
|
213
|
+
"climate.custom",
|
|
214
|
+
"Custom Thermostat",
|
|
215
|
+
features=[],
|
|
216
|
+
attributes={},
|
|
217
|
+
)
|
|
218
|
+
self.custom_called = False
|
|
219
|
+
|
|
220
|
+
def set_state(self, value, *, update=True):
|
|
221
|
+
self.custom_called = True
|
|
222
|
+
super().set_state(value, update=update)
|
|
223
|
+
|
|
224
|
+
entity = CustomClimate()
|
|
225
|
+
entity._api = MagicMock() # noqa: SLF001
|
|
226
|
+
entity._api.configured_entities.get.return_value = entity # noqa: SLF001
|
|
227
|
+
|
|
228
|
+
entity.set_state(climate.States.COOL, update=False)
|
|
229
|
+
assert entity.custom_called is True
|
|
230
|
+
assert entity.state == climate.States.COOL
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Tests for CoverEntity with built-in state management."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
from ucapi import cover
|
|
6
|
+
from ucapi_framework import CoverEntity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestCoverEntity:
|
|
10
|
+
"""Test CoverEntity state management."""
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_api(self):
|
|
14
|
+
"""Create a mock API for testing."""
|
|
15
|
+
api = MagicMock()
|
|
16
|
+
api.configured_entities.get.return_value = MagicMock(
|
|
17
|
+
attributes={cover.Attributes.STATE: cover.States.CLOSED}
|
|
18
|
+
)
|
|
19
|
+
return api
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def entity(self, mock_api):
|
|
23
|
+
"""Create a CoverEntity for testing."""
|
|
24
|
+
entity = CoverEntity(
|
|
25
|
+
"cover.test",
|
|
26
|
+
"Test Cover",
|
|
27
|
+
features=[cover.Features.OPEN, cover.Features.CLOSE],
|
|
28
|
+
attributes={cover.Attributes.STATE: cover.States.CLOSED},
|
|
29
|
+
)
|
|
30
|
+
entity._api = mock_api # noqa: SLF001
|
|
31
|
+
return entity
|
|
32
|
+
|
|
33
|
+
def test_initial_state(self, entity):
|
|
34
|
+
"""Test initial state from constructor attributes."""
|
|
35
|
+
# State was set to CLOSED in constructor
|
|
36
|
+
assert entity.state == cover.States.CLOSED
|
|
37
|
+
# These were not set, so should be None
|
|
38
|
+
assert entity.position is None
|
|
39
|
+
assert entity.tilt_position is None
|
|
40
|
+
|
|
41
|
+
def test_set_state_with_update(self, entity, mock_api):
|
|
42
|
+
"""Test set_state() calls entity.update() by default."""
|
|
43
|
+
entity.set_state(cover.States.OPEN, update=True)
|
|
44
|
+
|
|
45
|
+
# Verify internal state was updated
|
|
46
|
+
assert entity.state == cover.States.OPEN
|
|
47
|
+
|
|
48
|
+
# Verify update was called
|
|
49
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
50
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
51
|
+
entity_id, attributes = call_args[0]
|
|
52
|
+
assert entity_id == "cover.test"
|
|
53
|
+
assert cover.Attributes.STATE in attributes
|
|
54
|
+
assert attributes[cover.Attributes.STATE] == cover.States.OPEN
|
|
55
|
+
|
|
56
|
+
def test_set_state_without_update(self, entity, mock_api):
|
|
57
|
+
"""Test set_state(update=False) does not call entity.update()."""
|
|
58
|
+
entity.set_state(cover.States.OPEN, update=False)
|
|
59
|
+
|
|
60
|
+
# Verify internal state was updated
|
|
61
|
+
assert entity.state == cover.States.OPEN
|
|
62
|
+
|
|
63
|
+
# Verify update was NOT called
|
|
64
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
65
|
+
|
|
66
|
+
def test_set_state_transitions(self, entity, mock_api):
|
|
67
|
+
"""Test all valid state transitions."""
|
|
68
|
+
for state in [
|
|
69
|
+
cover.States.OPENING,
|
|
70
|
+
cover.States.OPEN,
|
|
71
|
+
cover.States.CLOSING,
|
|
72
|
+
cover.States.CLOSED,
|
|
73
|
+
cover.States.UNKNOWN,
|
|
74
|
+
cover.States.UNAVAILABLE,
|
|
75
|
+
]:
|
|
76
|
+
entity.set_state(state, update=False)
|
|
77
|
+
assert entity.state == state
|
|
78
|
+
|
|
79
|
+
def test_set_position(self, entity, mock_api):
|
|
80
|
+
"""Test set_position() updates state and calls update."""
|
|
81
|
+
entity.set_position(75, update=True)
|
|
82
|
+
|
|
83
|
+
assert entity.position == 75
|
|
84
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
85
|
+
|
|
86
|
+
def test_set_position_without_update(self, entity, mock_api):
|
|
87
|
+
"""Test set_position(update=False) does not call entity.update()."""
|
|
88
|
+
entity.set_position(75, update=False)
|
|
89
|
+
|
|
90
|
+
assert entity.position == 75
|
|
91
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
92
|
+
|
|
93
|
+
def test_set_tilt_position(self, entity, mock_api):
|
|
94
|
+
"""Test set_tilt_position() updates state and calls update."""
|
|
95
|
+
entity.set_tilt_position(45, update=True)
|
|
96
|
+
|
|
97
|
+
assert entity.tilt_position == 45
|
|
98
|
+
assert mock_api.configured_entities.update_attributes.called
|
|
99
|
+
|
|
100
|
+
def test_set_tilt_position_without_update(self, entity, mock_api):
|
|
101
|
+
"""Test set_tilt_position(update=False) does not call entity.update()."""
|
|
102
|
+
entity.set_tilt_position(45, update=False)
|
|
103
|
+
|
|
104
|
+
assert entity.tilt_position == 45
|
|
105
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
106
|
+
|
|
107
|
+
def test_set_attributes_bulk_update(self, entity, mock_api):
|
|
108
|
+
"""Test set_attributes() updates multiple attributes with single update call."""
|
|
109
|
+
entity.set_attributes(
|
|
110
|
+
state=cover.States.OPEN,
|
|
111
|
+
position=100,
|
|
112
|
+
tilt_position=50,
|
|
113
|
+
update=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Verify all internal state was updated
|
|
117
|
+
assert entity.state == cover.States.OPEN
|
|
118
|
+
assert entity.position == 100
|
|
119
|
+
assert entity.tilt_position == 50
|
|
120
|
+
|
|
121
|
+
# Verify update was called only once
|
|
122
|
+
assert mock_api.configured_entities.update_attributes.call_count == 1
|
|
123
|
+
|
|
124
|
+
# Verify all attributes were included in the update
|
|
125
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
126
|
+
entity_id, attributes = call_args[0]
|
|
127
|
+
assert entity_id == "cover.test"
|
|
128
|
+
assert len(attributes) == 3
|
|
129
|
+
assert attributes[cover.Attributes.STATE] == cover.States.OPEN
|
|
130
|
+
assert attributes[cover.Attributes.POSITION] == 100
|
|
131
|
+
assert attributes[cover.Attributes.TILT_POSITION] == 50
|
|
132
|
+
|
|
133
|
+
def test_set_attributes_without_update(self, entity, mock_api):
|
|
134
|
+
"""Test set_attributes(update=False) does not call entity.update()."""
|
|
135
|
+
entity.set_attributes(state=cover.States.OPEN, position=100, update=False)
|
|
136
|
+
|
|
137
|
+
# Verify internal state was updated
|
|
138
|
+
assert entity.state == cover.States.OPEN
|
|
139
|
+
assert entity.position == 100
|
|
140
|
+
|
|
141
|
+
# Verify update was NOT called
|
|
142
|
+
assert not mock_api.configured_entities.update_attributes.called
|
|
143
|
+
|
|
144
|
+
def test_set_attributes_ignores_none_values(self, entity, mock_api):
|
|
145
|
+
"""Test set_attributes() ignores None values."""
|
|
146
|
+
entity.set_attributes(state=cover.States.OPEN, position=None, update=True)
|
|
147
|
+
|
|
148
|
+
# Only state should be in internal storage
|
|
149
|
+
assert entity.state == cover.States.OPEN
|
|
150
|
+
assert entity.position is None
|
|
151
|
+
|
|
152
|
+
# Verify only state was included in update
|
|
153
|
+
call_args = mock_api.configured_entities.update_attributes.call_args
|
|
154
|
+
entity_id, attributes = call_args[0]
|
|
155
|
+
assert len(attributes) == 1
|
|
156
|
+
assert cover.Attributes.STATE in attributes
|
|
157
|
+
|
|
158
|
+
def test_property_getters_are_read_only(self, entity):
|
|
159
|
+
"""Test that property getters cannot be set directly."""
|
|
160
|
+
with pytest.raises(AttributeError):
|
|
161
|
+
entity.state = cover.States.OPEN # type: ignore[misc]
|
|
162
|
+
|
|
163
|
+
def test_all_cover_attributes(self, entity, mock_api):
|
|
164
|
+
"""Test setting all cover attributes."""
|
|
165
|
+
entity.set_attributes(
|
|
166
|
+
state=cover.States.OPEN,
|
|
167
|
+
position=100,
|
|
168
|
+
tilt_position=0,
|
|
169
|
+
update=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert entity.state == cover.States.OPEN
|
|
173
|
+
assert entity.position == 100
|
|
174
|
+
assert entity.tilt_position == 0
|
|
175
|
+
|
|
176
|
+
# Verify single update call
|
|
177
|
+
assert mock_api.configured_entities.update_attributes.call_count == 1
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestCoverEntityInheritance:
|
|
181
|
+
"""Test that CoverEntity can be subclassed and overridden."""
|
|
182
|
+
|
|
183
|
+
def test_custom_set_state(self):
|
|
184
|
+
"""Test that set_state can be overridden."""
|
|
185
|
+
|
|
186
|
+
class CustomCover(CoverEntity):
|
|
187
|
+
def __init__(self):
|
|
188
|
+
super().__init__(
|
|
189
|
+
"cover.custom",
|
|
190
|
+
"Custom Cover",
|
|
191
|
+
features=[],
|
|
192
|
+
attributes={},
|
|
193
|
+
)
|
|
194
|
+
self.custom_set_state_called = False
|
|
195
|
+
|
|
196
|
+
def set_state(self, value, *, update=True):
|
|
197
|
+
"""Override set_state to add custom logic."""
|
|
198
|
+
self.custom_set_state_called = True
|
|
199
|
+
super().set_state(value, update=update)
|
|
200
|
+
|
|
201
|
+
entity = CustomCover()
|
|
202
|
+
entity._api = MagicMock() # noqa: SLF001
|
|
203
|
+
entity._api.configured_entities.get.return_value = entity # noqa: SLF001
|
|
204
|
+
|
|
205
|
+
entity.set_state(cover.States.OPEN, update=False)
|
|
206
|
+
assert entity.custom_set_state_called is True
|
|
207
|
+
assert entity.state == cover.States.OPEN
|
|
208
|
+
|
|
209
|
+
def test_custom_property_getter(self):
|
|
210
|
+
"""Test that property getters can be overridden."""
|
|
211
|
+
|
|
212
|
+
class CustomCover(CoverEntity):
|
|
213
|
+
def __init__(self):
|
|
214
|
+
super().__init__(
|
|
215
|
+
"cover.custom",
|
|
216
|
+
"Custom Cover",
|
|
217
|
+
features=[],
|
|
218
|
+
attributes={},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def state(self):
|
|
223
|
+
"""Override state getter to always return OPEN."""
|
|
224
|
+
return cover.States.OPEN
|
|
225
|
+
|
|
226
|
+
entity = CustomCover()
|
|
227
|
+
# Even if internal state is None, getter returns OPEN
|
|
228
|
+
assert entity.state == cover.States.OPEN
|