Skip to content

Fix test framework running against real devices #1235

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 11, 2024
5 changes: 5 additions & 0 deletions kasa/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@ def config(self) -> DeviceConfig:
def model(self) -> str:
"""Returns the device model."""

@property
@abstractmethod
def _model_region(self) -> str:
"""Return device full model name and region."""

@property
@abstractmethod
def alias(self) -> str | None:
Expand Down
6 changes: 6 additions & 0 deletions kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,12 @@
sys_info = self._sys_info
return str(sys_info["model"])

@property
@requires_update
def _model_region(self) -> str:
"""Return device full model name and region."""
return self.model

Check warning on line 462 in kasa/iot/iotdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotdevice.py#L462

Added line #L462 was not covered by tests

@property # type: ignore
def alias(self) -> str | None:
"""Return device name (alias)."""
Expand Down
11 changes: 11 additions & 0 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,17 @@
"""Returns the device model."""
return str(self._info.get("model"))

@property
def _model_region(self) -> str:
"""Return device full model name and region."""
if (disco := self._discovery_info) and (
disco_model := disco.get("device_model")
):
return disco_model

Check warning on line 501 in kasa/smart/smartdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/smart/smartdevice.py#L501

Added line #L501 was not covered by tests
# Some devices have the region in the specs element.
region = f"({specs})" if (specs := self._info.get("specs")) else ""
return f"{self.model}{region}"

@property
def alias(self) -> str | None:
"""Returns the device alias or nickname."""
Expand Down
58 changes: 48 additions & 10 deletions tests/device_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
from collections.abc import AsyncGenerator

import pytest
Expand Down Expand Up @@ -142,7 +143,7 @@
)
ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART)

IP_MODEL_CACHE: dict[str, str] = {}
IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {}


def parametrize_combine(parametrized: list[pytest.MarkDecorator]):
Expand Down Expand Up @@ -448,6 +449,39 @@ def get_fixture_info(fixture, protocol):
return fixture_info


def get_nearest_fixture_to_ip(dev):
if isinstance(dev, SmartDevice):
protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"})
elif isinstance(dev, SmartCamera):
protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAMERA"})
else:
protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"})
assert protocol_fixtures, "Unknown device type"

# This will get the best fixture with a match on model region
if model_region_fixtures := filter_fixtures(
"", model_filter={dev._model_region}, fixture_list=protocol_fixtures
):
return next(iter(model_region_fixtures))

# This will get the best fixture based on model starting with the name.
if "(" in dev.model:
model, _, _ = dev.model.partition("(")
else:
model = dev.model
if model_fixtures := filter_fixtures(
"", model_startswith_filter=model, fixture_list=protocol_fixtures
):
return next(iter(model_fixtures))

if device_type_fixtures := filter_fixtures(
"", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures
):
return next(iter(device_type_fixtures))

return next(iter(protocol_fixtures))


@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator)
async def dev(request) -> AsyncGenerator[Device, None]:
"""Device fixture.
Expand All @@ -459,24 +493,28 @@ async def dev(request) -> AsyncGenerator[Device, None]:
dev: Device

ip = request.config.getoption("--ip")
username = request.config.getoption("--username")
password = request.config.getoption("--password")
username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME")
password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD")
if ip:
model = IP_MODEL_CACHE.get(ip)
fixture = IP_FIXTURE_CACHE.get(ip)

d = None
if not model:
if not fixture:
d = await _discover_update_and_close(ip, username, password)
IP_MODEL_CACHE[ip] = model = d.model

if model not in fixture_data.name:
IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d)
assert fixture
if fixture.name != fixture_data.name:
pytest.skip(f"skipping file {fixture_data.name}")
dev = d if d else await _discover_update_and_close(ip, username, password)
dev = None
else:
dev = d if d else await _discover_update_and_close(ip, username, password)
else:
dev = await get_device_for_fixture(fixture_data)

yield dev

await dev.disconnect()
if dev:
await dev.disconnect()


def get_parent_and_child_modules(device: Device, module_name):
Expand Down
20 changes: 15 additions & 5 deletions tests/fixtureinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ def filter_fixtures(
data_root_filter: str | None = None,
protocol_filter: set[str] | None = None,
model_filter: set[str] | None = None,
model_startswith_filter: str | None = None,
component_filter: str | ComponentFilter | None = None,
device_type_filter: Iterable[DeviceType] | None = None,
fixture_list: list[FixtureInfo] = FIXTURE_DATA,
):
"""Filter the fixtures based on supplied parameters.

Expand All @@ -127,12 +129,15 @@ def _model_match(fixture_data: FixtureInfo, model_filter: set[str]):
and (model := model_filter_list[0])
and len(model.split("_")) == 3
):
# return exact match
# filter string includes hw and fw, return exact match
return fixture_data.name == f"{model}.json"
file_model_region = fixture_data.name.split("_")[0]
file_model = file_model_region.split("(")[0]
return file_model in model_filter

def _model_startswith_match(fixture_data: FixtureInfo, starts_with: str):
return fixture_data.name.startswith(starts_with)

def _component_match(
fixture_data: FixtureInfo, component_filter: str | ComponentFilter
):
Expand Down Expand Up @@ -175,13 +180,17 @@ def _device_type_match(fixture_data: FixtureInfo, device_type):
filtered = []
if protocol_filter is None:
protocol_filter = {"IOT", "SMART"}
for fixture_data in FIXTURE_DATA:
for fixture_data in fixture_list:
if data_root_filter and data_root_filter not in fixture_data.data:
continue
if fixture_data.protocol not in protocol_filter:
continue
if model_filter is not None and not _model_match(fixture_data, model_filter):
continue
if model_startswith_filter is not None and not _model_startswith_match(
fixture_data, model_startswith_filter
):
continue
if component_filter and not _component_match(fixture_data, component_filter):
continue
if device_type_filter and not _device_type_match(
Expand All @@ -191,8 +200,9 @@ def _device_type_match(fixture_data: FixtureInfo, device_type):

filtered.append(fixture_data)

print(f"# {desc}")
for value in filtered:
print(f"\t{value.name}")
if desc:
print(f"# {desc}")
for value in filtered:
print(f"\t{value.name}")
filtered.sort()
return filtered
1 change: 1 addition & 0 deletions tests/smart/modules/test_firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ async def test_update_available_without_cloud(dev: SmartDevice):
pytest.param(False, pytest.raises(KasaException), id="not-available"),
],
)
@pytest.mark.requires_dummy()
async def test_firmware_update(
dev: SmartDevice,
mocker: MockerFixture,
Expand Down
2 changes: 2 additions & 0 deletions tests/test_aestransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
)
from kasa.httpclient import HttpClient

pytestmark = [pytest.mark.requires_dummy]

DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}

key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_bulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from .test_iotdevice import SYSINFO_SCHEMA


@bulb
@bulb_iot
async def test_bulb_sysinfo(dev: Device):
assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info)
Expand Down
7 changes: 6 additions & 1 deletion tests/test_childdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,13 @@ async def test_parent_property(dev: Device):


@has_children_smart
@pytest.mark.requires_dummy()
async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory):
"""Test a child device gets the time from it's parent module."""
"""Test a child device gets the time from it's parent module.

This is excluded from real device testing as the test often fail if the
device time is not in the past.
"""
if not dev.children:
pytest.skip(f"Device {dev} fixture does not have any children")

Expand Down
4 changes: 4 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
turn_on,
)

# The cli tests should be testing the cli logic rather than a physical device
# so mark the whole file for skipping with real devices.
pytestmark = [pytest.mark.requires_dummy]


@pytest.fixture()
def runner():
Expand Down
49 changes: 32 additions & 17 deletions tests/test_common_modules.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from datetime import datetime

import pytest
from freezegun.api import FrozenDateTimeFactory
from pytest_mock import MockerFixture
from zoneinfo import ZoneInfo

Expand Down Expand Up @@ -326,22 +325,38 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture):
assert new_preset_state.color_temp == new_preset.color_temp


async def test_set_time(dev: Device, freezer: FrozenDateTimeFactory):
async def test_set_time(dev: Device):
"""Test setting the device time."""
freezer.move_to("2021-01-09 12:00:00+00:00")
time_mod = dev.modules[Module.Time]
tz_info = time_mod.timezone
now = datetime.now(tz=tz_info)
now = now.replace(microsecond=0)
assert time_mod.time != now

await time_mod.set_time(now)
await dev.update()
assert time_mod.time == now

zone = ZoneInfo("Europe/Berlin")
now = datetime.now(tz=zone)
now = now.replace(microsecond=0)
await time_mod.set_time(now)
await dev.update()
assert time_mod.time == now
original_time = time_mod.time
original_timezone = time_mod.timezone

test_time = datetime.fromisoformat("2021-01-09 12:00:00+00:00")
test_time = test_time.astimezone(original_timezone)

try:
assert time_mod.time != test_time

await time_mod.set_time(test_time)
await dev.update()
assert time_mod.time == test_time

if (
isinstance(original_timezone, ZoneInfo)
and original_timezone.key != "Europe/Berlin"
):
test_zonezone = ZoneInfo("Europe/Berlin")
else:
test_zonezone = ZoneInfo("Europe/London")

# Just update the timezone
new_time = time_mod.time.astimezone(test_zonezone)
await time_mod.set_time(new_time)
await dev.update()
assert time_mod.time == new_time
finally:
# Reset back to the original
await time_mod.set_time(original_time)
await dev.update()
assert time_mod.time == original_time
4 changes: 4 additions & 0 deletions tests/test_device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@

from .conftest import DISCOVERY_MOCK_IP

# Device Factory tests are not relevant for real devices which run against
# a single device that has already been created via the factory.
pytestmark = [pytest.mark.requires_dummy]


def _get_connection_type_device_class(discovery_info):
if "result" in discovery_info:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
wallswitch_iot,
)

# A physical device has to respond to discovery for the tests to work.
pytestmark = [pytest.mark.requires_dummy]

UNSUPPORTED = {
"result": {
"device_id": "xx",
Expand Down
3 changes: 3 additions & 0 deletions tests/test_klapprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}

# Transport tests are not designed for real devices
pytestmark = [pytest.mark.requires_dummy]


class _mock_response:
def __init__(self, status, content: bytes):
Expand Down
11 changes: 7 additions & 4 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,10 +687,13 @@ def test_deprecated_protocol():
@device_iot
async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture):
"""Test query sensitive info redaction."""
device_id = "123456789ABCDEF"
cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][
"deviceId"
] = device_id
if isinstance(dev.protocol._transport, FakeIotTransport):
device_id = "123456789ABCDEF"
cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][
"deviceId"
] = device_id
else: # real device with --ip
device_id = dev.sys_info["deviceId"]

# Info no message logging
caplog.set_level(logging.INFO)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@


@device_smart
@pytest.mark.requires_dummy()
async def test_try_get_response(dev: SmartDevice, caplog):
mock_response: dict = {
"get_device_info": SmartErrorCode.PARAMS_ERROR,
Expand All @@ -37,6 +38,7 @@ async def test_try_get_response(dev: SmartDevice, caplog):


@device_smart
@pytest.mark.requires_dummy()
async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture):
mock_response: dict = {
"get_device_usage": {},
Expand Down
10 changes: 5 additions & 5 deletions tests/test_smartprotocol.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
from typing import cast

import pytest
import pytest_mock
Expand Down Expand Up @@ -420,10 +419,11 @@ async def test_smart_queries_redaction(
dev: SmartDevice, caplog: pytest.LogCaptureFixture
):
"""Test query sensitive info redaction."""
device_id = "123456789ABCDEF"
cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][
"device_id"
] = device_id
if isinstance(dev.protocol._transport, FakeSmartTransport):
device_id = "123456789ABCDEF"
dev.protocol._transport.info["get_device_info"]["device_id"] = device_id
else: # real device
device_id = dev.device_id

# Info no message logging
caplog.set_level(logging.INFO)
Expand Down
Loading
Loading