mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-10-07 13:23:55 +00:00
Add type annotations (parameter types and `-> None` return types) to all top-level test functions across 22 test files. This enables mypy to check test function bodies, catching type errors that were previously hidden.
242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
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 == "<Action>"
|
|
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
|
|
)
|