mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-07-08 06:41:59 +00:00
Add type annotations to all test functions across 8 files in `tests/iot/`, improving type safety and enabling mypy to check function bodies.
195 lines
7.5 KiB
Python
195 lines
7.5 KiB
Python
from datetime import UTC, datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
|
|
|
|
def test_expected_dst_behavior_for_index_cases() -> None:
|
|
"""Exercise _expected_dst_behavior_for_index for several representative indices."""
|
|
from kasa.iot.iottimezone import _expected_dst_behavior_for_index
|
|
|
|
# Posix-style DST zones
|
|
assert _expected_dst_behavior_for_index(10) is True # MST7MDT
|
|
assert _expected_dst_behavior_for_index(13) is True # CST6CDT
|
|
# Fixed-offset or fixed-abbreviation zones
|
|
assert _expected_dst_behavior_for_index(34) is False # Etc/GMT+2
|
|
assert _expected_dst_behavior_for_index(18) is False # EST
|
|
# Invalid index should raise KeyError
|
|
with pytest.raises(KeyError):
|
|
_expected_dst_behavior_for_index(999)
|
|
|
|
|
|
async def test_guess_timezone_by_offset_fixed_fallback_unit() -> None:
|
|
"""When no ZoneInfo matches, return a fixed-offset tzinfo."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
year = datetime.now(UTC).year
|
|
when = datetime(year, 1, 15, 12, tzinfo=UTC)
|
|
offset = timedelta(minutes=2) # unlikely to match any real zone
|
|
tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when)
|
|
assert tz.utcoffset(when) == offset
|
|
|
|
|
|
async def test_guess_timezone_by_offset_candidates_unit() -> None:
|
|
"""Cover naive when_utc branch and candidate selection path (non-empty candidates)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# naive datetime hits the 'naive -> UTC' branch
|
|
when = datetime(2025, 1, 15, 12)
|
|
offset = timedelta(0)
|
|
tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when)
|
|
|
|
# Should choose a ZoneInfo candidate (not the fixed-offset fallback), with matching offset
|
|
assert isinstance(tz, ZoneInfo)
|
|
assert tz.utcoffset(when.replace(tzinfo=UTC)) == offset
|
|
|
|
|
|
async def test_guess_timezone_by_offset_dst_expected_true_filters(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""dst_expected=True should prefer a DST-observing zone when possible."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(
|
|
timedelta(0), when_utc=when, dst_expected=True
|
|
)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
if isinstance(tz, ZoneInfo):
|
|
jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
assert jan != jul # observes DST
|
|
|
|
|
|
async def test_guess_timezone_by_offset_dst_expected_false_prefers_non_dst() -> None:
|
|
"""dst_expected=False should prefer a non-DST zone and skip DST candidates (covers False branch)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(
|
|
timedelta(0), when_utc=when, dst_expected=False
|
|
)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
if isinstance(tz, ZoneInfo):
|
|
jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
assert jan == jul # non-DST zone chosen
|
|
|
|
|
|
async def test_guess_timezone_by_offset_handles_missing_zoneinfo_unit(
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""Cover the ZoneInfoNotFoundError continue path within guess_timezone_by_offset."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original = tzmod.CachedZoneInfo.get_cached_zone_info
|
|
|
|
async def flaky_get(name: str):
|
|
# Force the first entry to raise to exercise the except path (143-144)
|
|
first_name = next(iter(tzmod.TIMEZONE_INDEX.values()))
|
|
if name == first_name:
|
|
raise ZNF("unavailable on host")
|
|
return await original(name)
|
|
|
|
mocker.patch.object(tzmod.CachedZoneInfo, "get_cached_zone_info", new=flaky_get)
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(timedelta(0), when_utc=when)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
|
|
|
|
async def test_get_timezone_index_direct_match() -> None:
|
|
"""If ZoneInfo key is in TIMEZONE_INDEX, return index directly."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
idx = await tzmod.get_timezone_index(ZoneInfo("GB"))
|
|
assert idx == 39 # "GB" is mapped to index 39
|
|
|
|
|
|
async def test_get_timezone_index_non_zoneinfo_unit() -> None:
|
|
"""Exercise get_timezone_index path when input tzinfo is not a ZoneInfo instance."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# Fixed offset +0 should match a valid index (e.g., UCT/Africa/Monrovia)
|
|
idx = await tzmod.get_timezone_index(timezone(timedelta(0)))
|
|
assert isinstance(idx, int)
|
|
assert 0 <= idx <= 109
|
|
|
|
|
|
async def test_get_timezone_index_skips_missing_unit(mocker: MockerFixture) -> None:
|
|
"""Cover ZoneInfoNotFoundError path in get_timezone_index loop and successful match."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original_get_tz = tzmod.get_timezone
|
|
|
|
async def side_effect(i: int):
|
|
if i < 5:
|
|
raise ZNF("unavailable on host")
|
|
return await original_get_tz(i)
|
|
|
|
mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect)
|
|
|
|
# Use a ZoneInfo not directly present in TIMEZONE_INDEX values to avoid early return
|
|
idx = await tzmod.get_timezone_index(ZoneInfo("Europe/London"))
|
|
assert isinstance(idx, int)
|
|
assert 0 <= idx <= 109
|
|
assert idx >= 5
|
|
|
|
|
|
async def test_get_timezone_index_raises_for_unmatched_unit() -> None:
|
|
"""Ensure get_timezone_index completes loop and raises when no match exists (covers raise branch)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# Uncommon 2-minute offset won't match any real zone in TIMEZONE_INDEX
|
|
with pytest.raises(ValueError, match="Device does not support timezone"):
|
|
await tzmod.get_timezone_index(timezone(timedelta(minutes=2)))
|
|
|
|
|
|
async def test_get_matching_timezones_branches_unit(mocker: MockerFixture) -> None:
|
|
"""Cover initial append, except path, and duplicate suppression in get_matching_timezones."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original_get_tz = tzmod.get_timezone
|
|
|
|
async def side_effect(i: int):
|
|
# Force one miss to hit the except path
|
|
if i == 0:
|
|
raise ZNF("unavailable on host")
|
|
return await original_get_tz(i)
|
|
|
|
mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect)
|
|
|
|
# 'GB' is in TIMEZONE_INDEX; passing ZoneInfo('GB') will trigger initial append
|
|
matches = await tzmod.get_matching_timezones(ZoneInfo("GB"))
|
|
assert "GB" in matches # initial append done
|
|
# Loop should find GB again but not duplicate it
|
|
|
|
|
|
async def test_get_matching_timezones_non_zoneinfo_unit() -> None:
|
|
"""Exercise get_matching_timezones when input tzinfo is not a ZoneInfo (skips initial append)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
matches = await tzmod.get_matching_timezones(timezone(timedelta(0)))
|
|
assert isinstance(matches, list)
|
|
assert len(matches) > 0
|
|
|
|
|
|
async def test_get_timezone_out_of_range_defaults_to_utc() -> None:
|
|
"""Out-of-range index should log and default to UTC."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
tz = await tzmod.get_timezone(-1)
|
|
assert isinstance(tz, ZoneInfo)
|
|
assert tz.key in ("Etc/UTC", "UTC") # platform alias acceptable
|
|
|
|
tz2 = await tzmod.get_timezone(999)
|
|
assert isinstance(tz2, ZoneInfo)
|
|
assert tz2.key in ("Etc/UTC", "UTC")
|