import logging from unittest.mock import AsyncMock, MagicMock, patch import pytest from pytest_mock import MockerFixture from kasa import Device, Feature, KasaException, Module from kasa.iot import IotStrip _LOGGER = logging.getLogger(__name__) class DummyDevice: pass @pytest.fixture def dummy_feature() -> Feature: # create_autospec for device slows tests way too much, so we use a dummy here feat = Feature( device=DummyDevice(), # type: ignore[arg-type] id="dummy_feature", name="dummy_feature", attribute_getter="dummygetter", attribute_setter="dummysetter", container=None, icon="mdi:dummy", type=Feature.Type.Switch, unit_getter=lambda: "dummyunit", ) return feat def test_feature_api(dummy_feature: Feature) -> None: """Test all properties of a dummy feature.""" assert dummy_feature.device is not None assert dummy_feature.name == "dummy_feature" assert dummy_feature.attribute_getter == "dummygetter" assert dummy_feature.attribute_setter == "dummysetter" assert dummy_feature.container is None assert dummy_feature.icon == "mdi:dummy" assert dummy_feature.type == Feature.Type.Switch assert dummy_feature.unit == "dummyunit" @pytest.mark.parametrize( "read_only_type", [Feature.Type.Sensor, Feature.Type.BinarySensor] ) def test_feature_setter_on_sensor(read_only_type: Feature.Type) -> None: """Test that creating a sensor feature with a setter causes an error.""" with pytest.raises(ValueError, match="Invalid type for configurable feature"): Feature( device=DummyDevice(), # type: ignore[arg-type] id="dummy_error", name="dummy error", attribute_getter="dummygetter", attribute_setter="dummysetter", type=read_only_type, ) def test_feature_value(dummy_feature: Feature) -> None: """Verify that property gets accessed on *value* access.""" dummy_feature.attribute_getter = "test_prop" dummy_feature.device.test_prop = "dummy" # type: ignore[attr-defined] assert dummy_feature.value == "dummy" def test_feature_value_container(mocker: MockerFixture, dummy_feature: Feature) -> None: """Test that container's attribute is accessed when expected.""" class DummyContainer: @property def test_prop(self): return "dummy" dummy_feature.container = DummyContainer() # type: ignore[assignment] dummy_feature.attribute_getter = "test_prop" mock_dev_prop = mocker.patch.object( dummy_feature, "test_prop", new_callable=mocker.PropertyMock, create=True ) assert dummy_feature.value == "dummy" mock_dev_prop.assert_not_called() def test_feature_value_callable(dev: Device, dummy_feature: Feature) -> None: """Verify that callables work as *attribute_getter*.""" dummy_feature.attribute_getter = lambda x: "dummy value" assert dummy_feature.value == "dummy value" async def test_feature_setter( dev: Device, mocker: MockerFixture, dummy_feature: Feature ) -> None: """Verify that *set_value* calls the defined method.""" mock_set_dummy = mocker.patch.object( dummy_feature.device, "set_dummy", create=True, new_callable=AsyncMock ) dummy_feature.attribute_setter = "set_dummy" await dummy_feature.set_value("dummy value") mock_set_dummy.assert_called_with("dummy value") async def test_feature_setter_read_only(dummy_feature: Feature) -> None: """Verify that read-only feature raises an exception when trying to change it.""" dummy_feature.attribute_setter = None with pytest.raises(ValueError, match="Tried to set read-only feature"): await dummy_feature.set_value("value for read only feature") async def test_feature_action(mocker: MockerFixture) -> None: """Test that setting value on button calls the setter.""" feat = Feature( device=DummyDevice(), # type: ignore[arg-type] id="dummy_feature", name="dummy_feature", attribute_setter="call_action", container=None, icon="mdi:dummy", type=Feature.Type.Action, ) mock_call_action = mocker.patch.object( feat.device, "call_action", create=True, new_callable=AsyncMock ) assert feat.value == "" await feat.set_value(1234) mock_call_action.assert_called() @pytest.mark.xdist_group(name="caplog") async def test_feature_choice_list( dummy_feature: Feature, caplog: pytest.LogCaptureFixture, mocker: MockerFixture ) -> None: """Test the choice feature type.""" dummy_feature.type = Feature.Type.Choice dummy_feature.choices_getter = lambda: ["first", "second"] mock_setter = mocker.patch.object( dummy_feature.device, "dummysetter", create=True, new_callable=AsyncMock ) await dummy_feature.set_value("first") mock_setter.assert_called_with("first") mock_setter.reset_mock() with pytest.raises( # noqa: PT012 ValueError, match="Unexpected value for dummy_feature: 'invalid' (?: - allowed: .*)?", ): await dummy_feature.set_value("invalid") assert "Unexpected value" in caplog.text mock_setter.assert_not_called() @pytest.mark.parametrize("precision_hint", [1, 2, 3]) async def test_precision_hint(dummy_feature: Feature, precision_hint: int) -> None: """Test that precision hint works as expected.""" dummy_value = 3.141593 dummy_feature.type = Feature.Type.Sensor dummy_feature.precision_hint = precision_hint dummy_feature.attribute_getter = lambda x: dummy_value assert dummy_feature.value == dummy_value assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) async def _test_feature_setter( dev: Device, feat: Feature, query_mock: MagicMock ) -> None: """Exercise one configurable feature setter.""" # setters that do not call set on the device itself. internal_setters = {"pan_step", "tilt_step"} if feat.attribute_setter is None: return # IotStrip makes calls via its children. expecting_call = feat.id not in internal_setters and not isinstance(dev, IotStrip) if feat.type == Feature.Type.Number: await feat.set_value(feat.minimum_value) elif feat.type == Feature.Type.Switch: await feat.set_value(True) elif feat.type == Feature.Type.Action: await feat.set_value("dummyvalue") elif feat.type == Feature.Type.Choice: choices = feat.choices if choices is None: raise AssertionError(f"Choice feature {feat.id} has no choices") await feat.set_value(choices[0]) elif feat.type == Feature.Type.Unknown: _LOGGER.warning("Feature '%s' has no type, cannot test the setter", feat) expecting_call = False else: raise NotImplementedError(f"set_value not implemented for {feat.type}") if expecting_call: query_mock.assert_called() async def _test_device_feature_setters(dev: Device) -> list[Exception]: """Exercise all configurable feature setters and collect unexpected errors.""" exceptions: list[Exception] = [] for feat in dev.features.values(): try: if isinstance(feat.container, Module): patch_dev = feat.container._device elif feat.container is not None: patch_dev = feat.container else: patch_dev = feat.device with ( patch.object(patch_dev.protocol, "query", name=feat.id) as query, # Patch update in case the feature setter invokes it. patch.object(patch_dev, "update"), ): await _test_feature_setter(dev, feat, query) # Allow library exceptions to avoid mocking valid device responses. except KasaException: pass except Exception as ex: ex.add_note(f"Exception when trying to set {feat} on {dev}") exceptions.append(ex) return exceptions async def test_feature_setters(dev: Device) -> None: """Test that all feature setters query something.""" exceptions = await _test_device_feature_setters(dev) for child in dev.children: exceptions.extend(await _test_device_feature_setters(child)) if exceptions: raise ExceptionGroup( "Got exceptions while testing attribute_setters", exceptions )