Fix more pre-existing ty diagnostics in test files

Continues narrowing SamsungEntityDescription accesses to the correct
subclass and asserting Optional write_fn/match_fn/exists_fn fields are
set before calling them, per the pattern established in the previous
commit.
This commit is contained in:
Marc Billow
2026-08-03 00:03:08 +00:00
parent daf7e3787f
commit 07061c734d
18 changed files with 430 additions and 122 deletions
+11 -4
View File
@@ -13,6 +13,7 @@ raw code until a reporter can map codes to names in the SmartThings app.
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc, SwitchDesc
from tests.conftest import _load_device
@@ -53,9 +54,12 @@ def test_buzzer_sound_present_and_writable():
desc = next(
e
for e in air_dresser.REGISTRY.capabilities["/buzzersound/vs/0"][0].entities
if e.key == "buzzer_sound"
if e.key == "buzzer_sound" and isinstance(e, SelectDesc)
)
path, body = desc.write_fn("Off", {})
assert desc.write_fn is not None
result = desc.write_fn("Off", {})
assert result is not None
path, body = result
assert path == ["buzzersound", "vs", "0"]
assert body == {"setBuzzerSound": "Off"}
@@ -75,8 +79,11 @@ def test_sanitize_present_and_toggles():
desc = next(
e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
if e.key == "sanitize"
if e.key == "sanitize" and isinstance(e, SwitchDesc)
)
path, body = desc.write_fn("On", {})
assert desc.write_fn is not None
result = desc.write_fn("On", {})
assert result is not None
path, body = result
assert path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"}
@@ -11,6 +11,7 @@ capability #162's dump doesn't report at all.
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SwitchDesc
from tests.conftest import _load_device
@@ -77,9 +78,12 @@ def test_sanitize_present_and_toggles():
desc = next(
e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
if e.key == "sanitize"
if e.key == "sanitize" and isinstance(e, SwitchDesc)
)
path, body = desc.write_fn("On", {})
assert desc.write_fn is not None
result = desc.write_fn("On", {})
assert result is not None
path, body = result
assert path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"}
assert desc.write_fn("Sparkle", {}) is None
+3 -2
View File
@@ -19,9 +19,10 @@ the same model with a slightly different token set (no ``Spi``,
gating handles the same way it handles newer boards.
"""
from typing import ClassVar
from typing import ClassVar, cast
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
airconditioner,
@@ -82,7 +83,7 @@ def _climate(resources, coordinator=None):
bound, _ = _discover(resources)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(
coordinator or _FakeCoordinator(resources),
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
climate_bound,
)
+45 -12
View File
@@ -10,7 +10,12 @@ from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
from custom_components.localthings.registry.entities import (
ClimateDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from tests.conftest import _load_device
@@ -103,7 +108,9 @@ def test_climate_write_targets():
/power/0 is absent on most boards and a non-authoritative mirror where
present; /temperature/desired/0 is only written via the temperature_ocf
kind, on boards that have the OCF pair)."""
write = airconditioner.CLIMATE.entities[0].write_fn
climate_desc = next(e for e in airconditioner.CLIMATE.entities if isinstance(e, ClimateDesc))
assert climate_desc.write_fn is not None
write = climate_desc.write_fn
assert write(("power", True), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "On"})
assert write(("power", False), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "Off"})
assert write(("mode", "Heat"), {}) == (
@@ -285,13 +292,17 @@ def test_caww_tp2_sac_installationinfo_is_ignored():
def test_mute_once_write_target():
write = airconditioner.MUTE_ONCE.entities[0].write_fn
desc = next(e for e in airconditioner.MUTE_ONCE.entities if isinstance(e, SwitchDesc))
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "On"})
assert write("Off", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "Off"})
def test_display_light_write_target():
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
desc = next(e for e in airconditioner.DISPLAY_LIGHT.entities if isinstance(e, SwitchDesc))
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == (["light", "vs", "0"], {"mode": "On"})
assert write("Off", {}) == (["light", "vs", "0"], {"mode": "Off"})
@@ -375,7 +386,12 @@ def test_display_light_option_parsing_and_gating():
def test_mode_options_display_light_write_is_inverted_single_token():
"""Turning the lamp ON writes the inverted 'Light_Off' token as a
single-element options list (single-token merge); OFF writes 'Light_On'."""
sw = next(e for e in airconditioner.CLIMATE.entities if e.key == "display_light")
sw = next(
e
for e in airconditioner.CLIMATE.entities
if e.key == "display_light" and isinstance(e, SwitchDesc)
)
assert sw.write_fn is not None
assert sw.write_fn("On", {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Light_Off"]},
@@ -433,12 +449,14 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent():
CURRENT_TEMPERATURE when a device (like this one) reports both
/temperature/current/0 and /temperatures/vs/0."""
match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn
assert match is not None
assert match({}, {"/temperature/current/0": {}}) is False
assert match({}, {}) is True
def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn is not None
rep = {"x.com.samsung.da.humidity": "0", "x.com.samsung.da.fivepercentHumidity": "42"}
assert desc.rep_fn(rep) == 42.0
@@ -448,6 +466,7 @@ def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent():
Their plain field is not stuck -- it carries a reading while Air monitoring
is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"x.com.samsung.da.humidity": "51"}) == 51.0
assert desc.rep_fn({"x.com.samsung.da.humidity": "0"}) is None
assert desc.rep_fn({}) is None
@@ -461,6 +480,7 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through():
fallback field collapses 0 -- fivepercentHumidity's 0 is a real
reading."""
desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"x.com.samsung.da.fivepercentHumidity": "0"}) == 0.0
@@ -580,21 +600,25 @@ def test_lnx_rac_heatpump_enable_switches_are_writable():
def test_lnx_rac_heatpump_absence_power_saving_write_target():
write = next(
desc = next(
e
for e in airconditioner.ABSENCE_POWER_SAVING.entities
if e.key == "absence_power_saving_active"
).write_fn
if e.key == "absence_power_saving_active" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "On"})
assert write("Off", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "Off"})
def test_lnx_rac_heatpump_motion_detect_wind_write_target():
write = next(
desc = next(
e
for e in airconditioner.MOTION_DETECT_WIND.entities
if e.key == "motion_detect_wind_active"
).write_fn
if e.key == "motion_detect_wind_active" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == (
["option", "motiondetectwind", "stateful", "vs", "0"],
{"status": "On"},
@@ -710,6 +734,7 @@ def test_tropical_night_absent_when_no_sleep_token():
reg = for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
assert reg is not None
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert "tropical_night_mode" not in state
@@ -723,6 +748,7 @@ def test_tropical_night_state_levels_across_fixtures():
r = for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
assert r is not None
return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get(
"tropical_night_mode"
)
@@ -736,13 +762,18 @@ def test_air_filter_usage_hours_reads_raw_count():
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
filter replacement -- total_increasing, not measurement. Unit comes from
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_usage_hours")
desc = next(
e
for e in airconditioner.AIR_FILTER.entities
if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc)
)
assert desc.value_fn("41") == 41
assert desc.value_fn(41) == 41
assert desc.value_fn(None) is None
assert desc.value_fn("not-a-number") is None
assert desc.device_class == "duration"
assert desc.state_class == "total_increasing"
assert desc.unit_fn is not None
assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Hour"}) == "h"
assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Minute"}) == "min"
assert desc.unit_fn({}) == "h" # static fallback when the field is absent
@@ -758,6 +789,7 @@ def test_air_filter_threshold_is_writable_select():
desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_threshold")
assert isinstance(desc, SelectDesc)
assert desc.options_field == "x.com.samsung.da.supportedFilterDesiredUsage"
assert desc.exists_fn is not None
assert (
desc.exists_fn(
{"x.com.samsung.da.supportedFilterDesiredUsage": ["180", "300", "500", "700"]}, {}
@@ -770,6 +802,7 @@ def test_air_filter_threshold_is_writable_select():
assert desc.value_fn(500) == "500"
assert desc.value_fn(None) is None
# Write POSTs the selected option as the scalar field.
assert desc.write_fn is not None
assert desc.write_fn("700", {}) == (
["filter", "airdustfilter", "vs", "0"],
{"x.com.samsung.da.filterDesiredUsage": "700"},
@@ -11,13 +11,14 @@ any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a
second numeric scale.
"""
from typing import ClassVar
from typing import ClassVar, cast
from custom_components.localthings.climate import (
_DEVICE_TO_FAN,
LocalThingsClimate,
_wind_strength_label,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
@@ -53,9 +54,12 @@ def _climate(resources, coordinator=None):
reg = by_type.for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
assert reg is not None
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound)
return LocalThingsClimate(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), climate_bound
)
def test_wind_strength_label_reads_the_devices_own_modes_name():
+6 -1
View File
@@ -1,5 +1,7 @@
"""Tests for samsung_appliance/registry/by_type."""
from typing import cast
import pytest
from custom_components.localthings.registry.by_type import (
@@ -110,7 +112,10 @@ class TestBoardTokens:
from custom_components.localthings.registry.by_type import _board_tokens
assert _board_tokens("", "|") == []
assert _board_tokens(None, "|") == []
# value is annotated `str`, but the implementation is defensive
# against a caller passing a genuinely missing modelNum/description
# (see the `value or ""` in _board_tokens) -- exercise that directly.
assert _board_tokens(cast(str, None), "|") == []
class TestBoardTokenTable:
+1 -1
View File
@@ -14,7 +14,7 @@ def test_capability_defaults():
def test_capability_is_frozen():
c = Capability(href="/kidslock/vs/0", entities=())
try:
c.href = "/other/vs/0"
setattr(c, "href", "/other/vs/0")
except Exception:
return
raise AssertionError("expected frozen dataclass")
+29 -3
View File
@@ -135,13 +135,19 @@ class TestPowerFallback:
desc = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
path, body = desc.write_fn("On", {})
assert desc.write_fn is not None
result = desc.write_fn("On", {})
assert result is not None
path, body = result
assert path == ["power", "0"]
assert body == {"value": True}
assert desc.write_fn("Off", {})[1] == {"value": False}
off_result = desc.write_fn("Off", {})
assert off_result is not None
assert off_result[1] == {"value": False}
def test_vs_fallback_binds_only_when_generic_absent(self):
assert common.POWER_VS_FALLBACK.href == "/power/vs/0"
assert common.POWER_VS_FALLBACK.match_fn is not None
assert common.POWER_VS_FALLBACK.match_fn({}, {"/power/vs/0": {}}) is True
assert common.POWER_VS_FALLBACK.match_fn({}, {"/power/0": {}, "/power/vs/0": {}}) is False
@@ -149,7 +155,10 @@ class TestPowerFallback:
desc = next(e for e in common.POWER_VS_FALLBACK.entities if isinstance(e, SwitchDesc))
assert desc.value_fn("On") is True
assert desc.value_fn("Off") is False
path, body = desc.write_fn("On", {})
assert desc.write_fn is not None
result = desc.write_fn("On", {})
assert result is not None
path, body = result
assert path == ["power", "vs", "0"]
assert body == {"x.com.samsung.da.power": "On"}
@@ -160,6 +169,8 @@ class TestPowerFallback:
"/power/0": {"value": True},
"/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "false"},
}
assert switch.exists_fn is not None
assert sensor.exists_fn is not None
assert switch.exists_fn(resources["/power/0"], resources) is False
assert sensor.exists_fn(resources["/power/0"], resources) is True
@@ -170,6 +181,8 @@ class TestPowerFallback:
"/power/0": {"value": True},
"/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "true"},
}
assert switch.exists_fn is not None
assert sensor.exists_fn is not None
assert switch.exists_fn(resources["/power/0"], resources) is True
assert sensor.exists_fn(resources["/power/0"], resources) is False
@@ -177,6 +190,8 @@ class TestPowerFallback:
switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc))
resources = {"/power/0": {"value": True}}
assert switch.exists_fn is not None
assert sensor.exists_fn is not None
assert switch.exists_fn(resources["/power/0"], resources) is True
assert sensor.exists_fn(resources["/power/0"], resources) is False
@@ -291,6 +306,7 @@ class TestKidsLockFallback:
assert desc.value_fn(True) is False # value=True -> Off=Locked
def test_vs_fallback_gated(self):
assert common.KIDS_LOCK_VS_FALLBACK.match_fn is not None
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {"/kidslock/vs/0": {}}) is True
assert (
common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {"/kidslock/0": {}, "/kidslock/vs/0": {}})
@@ -316,6 +332,7 @@ class TestRemoteControlFallback:
assert desc.value_fn(False) is False
def test_vs_fallback_gated(self):
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn is not None
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {"/remotectrl/vs/0": {}}) is True
assert (
common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
@@ -351,18 +368,22 @@ class TestEnergyMeter:
def test_power_watts_hidden_for_dead_sentinel(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts")
assert pw.exists_fn is not None
assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False
def test_power_watts_shown_for_real_value(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts")
assert pw.exists_fn is not None
assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "150"}, {}) is True
def test_energy_kwh_hidden_when_cumulative_power_absent(self):
kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh")
assert kwh.exists_fn is not None
assert kwh.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False
def test_energy_kwh_shown_when_present(self):
kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh")
assert kwh.exists_fn is not None
assert kwh.exists_fn({"x.com.samsung.da.cumulativePower": "58900"}, {}) is True
def test_both_entities_included_on_true_stub(self):
@@ -371,6 +392,8 @@ class TestEnergyMeter:
but data isn't fetched yet; include both so sub-polls populate them."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts")
kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh")
assert pw.exists_fn is not None
assert kwh.exists_fn is not None
stub = {"href": "/energy/consumption/vs/0"}
assert pw.exists_fn(stub, {}) is True
assert kwh.exists_fn(stub, {}) is True
@@ -381,6 +404,8 @@ class TestEnergyMeter:
resource doesn't get a phantom always-"unknown" entity (issue #127)."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts")
kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh")
assert pw.exists_fn is not None
assert kwh.exists_fn is not None
assert pw.exists_fn({}, {}) is False
assert kwh.exists_fn({}, {}) is False
@@ -388,6 +413,7 @@ class TestEnergyMeter:
"""A populated rep that lacks instantaneousPower must not spawn a
phantom power sensor (the exists_fn replaces the field-presence gate)."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts")
assert pw.exists_fn is not None
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
+7 -4
View File
@@ -14,12 +14,14 @@ only the minimal body over the wire.
from __future__ import annotations
from typing import cast
from unittest.mock import AsyncMock
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from custom_components.localthings.const import (
CONF_HOST,
@@ -31,6 +33,7 @@ from custom_components.localthings.const import (
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.subdevices import Subdevice
@@ -65,7 +68,7 @@ def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry.add_to_hass(hass)
coord = LocalThingsCoordinator(hass, entry)
coord.async_request_refresh = AsyncMock()
coord._session = _FakeSendSession()
coord._session = cast(DtlsCoapSession, _FakeSendSession())
return coord
@@ -80,7 +83,7 @@ async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
)
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
bound = BoundEntity(href=href, capability=None, desc=desc)
bound = BoundEntity(href=href, capability=Capability(), desc=desc)
await coordinator.async_send_command(bound, "1D")
@@ -102,7 +105,7 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
)
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
bound = BoundEntity(href=href, capability=None, desc=desc)
bound = BoundEntity(href=href, capability=Capability(), desc=desc)
await coordinator.async_send_command(bound, "1D")
@@ -127,7 +130,7 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
desc = ClimateDesc(key="climate", translation_key="airconditioner", write_fn=_climate_write)
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
return BoundEntity(href=href, capability=Capability(), desc=desc, subdevice=subdevice)
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
+3 -1
View File
@@ -4,6 +4,7 @@ from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device
@@ -74,13 +75,14 @@ def test_course_bound_to_shared_course_vs_0():
the shipped dryer_cycle_table_03 translations, consistent with
washer/dishwasher."""
assert dryer.DRYER_COURSE.href == "/course/vs/0"
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == "cycle")
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc))
assert callable(desc.translation_key)
table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}}
assert desc.translation_key(table_03) == "dryer_cycle_table_03"
assert desc.translation_key({}) == "cycle"
assert desc.options is laundry.cycle_options
rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]}
assert desc.rep_fn is not None
assert desc.rep_fn(rep) == "16"
+4 -1
View File
@@ -1,5 +1,8 @@
"""Tests for translated entity naming and dynamic instance placeholders."""
from typing import cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
@@ -23,7 +26,7 @@ def _make_entity(desc, href="/x/vs/0", key_override=None, instance="", instance_
key_override=key_override,
instance_name=instance_name,
)
return LocalThingsEntity(_FakeCoordinator(), bound)
return LocalThingsEntity(cast(LocalThingsCoordinator, _FakeCoordinator()), bound)
def test_descriptor_key_is_the_default_translation_key():
+61 -18
View File
@@ -1,8 +1,15 @@
"""Tests for fridge-specific capabilities."""
from typing import ClassVar
from collections.abc import Callable
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities import fridge
from custom_components.localthings.registry.entities import (
NumberDesc,
SelectDesc,
SensorDesc,
)
class TestTempCurrentGeneric:
@@ -11,15 +18,18 @@ class TestTempCurrentGeneric:
resource in Celsius."""
def test_unit_reads_celsius(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
desc = next(e for e in fridge.TEMP_CURRENT_GENERIC.entities if isinstance(e, SensorDesc))
assert desc.unit_fn is not None
assert desc.unit_fn({"temperature": 3.0, "units": "C"}) == "°C"
def test_unit_reads_fahrenheit(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
desc = next(e for e in fridge.TEMP_CURRENT_GENERIC.entities if isinstance(e, SensorDesc))
assert desc.unit_fn is not None
assert desc.unit_fn({"temperature": 5.0, "units": "F"}) == "°F"
def test_unit_defaults_to_fahrenheit_when_missing(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
desc = next(e for e in fridge.TEMP_CURRENT_GENERIC.entities if isinstance(e, SensorDesc))
assert desc.unit_fn is not None
assert desc.unit_fn({"temperature": 5.0}) == "°F"
@@ -32,23 +42,27 @@ class TestDoorGeneric:
def test_reads_bare_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"openState": "Open"}) is True
assert desc.rep_fn({"openState": "Close"}) is False
def test_reads_vendor_prefixed_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"x.com.samsung.da.openState": "Open"}) is True
assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False
def test_prefers_bare_field_when_both_present(self):
desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn is not None
rep = {"openState": "Open", "x.com.samsung.da.openState": "Close"}
assert desc.rep_fn(rep) is True
class TestTempSetpointGeneric:
def test_unit_reads_celsius(self):
desc = fridge.TEMP_SETPOINT.entities[0]
desc = next(e for e in fridge.TEMP_SETPOINT.entities if isinstance(e, NumberDesc))
assert desc.unit_fn is not None
assert desc.unit_fn({"temperature": -19.0, "units": "C"}) == "°C"
@@ -58,8 +72,11 @@ class TestTemperaturesFallback:
def test_freezer_unit_celsius(self):
desc = next(
e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "freezer_temperature"
e
for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == "freezer_temperature" and isinstance(e, SensorDesc)
)
assert desc.unit_fn is not None
rep = {
"x.com.samsung.da.items": [
{
@@ -79,8 +96,11 @@ class TestTemperaturesFallback:
def test_fridge_unit_fahrenheit(self):
desc = next(
e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "fridge_temperature"
e
for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == "fridge_temperature" and isinstance(e, SensorDesc)
)
assert desc.unit_fn is not None
rep = {
"x.com.samsung.da.items": [
{
@@ -94,8 +114,11 @@ class TestTemperaturesFallback:
def test_unit_defaults_to_fahrenheit_when_item_missing(self):
desc = next(
e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "freezer_temperature"
e
for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == "freezer_temperature" and isinstance(e, SensorDesc)
)
assert desc.unit_fn is not None
assert desc.unit_fn({"x.com.samsung.da.items": []}) == "°F"
@@ -130,17 +153,20 @@ class TestRefrigerationFallback:
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == "defrost_active")
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
assert desc.exists_fn is not None
assert desc.exists_fn({}, {"/refrigeration/0": {}}) is True
assert desc.exists_fn({}, {"/refrigeration/0": {}, "/defrost/block/vs/0": {}}) is False
def test_rapid_switches_hidden_when_vs_href_present(self):
for key in ("rapid_fridge", "rapid_freezing"):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn is not None
assert desc.exists_fn({}, {"/refrigeration/vs/0": {}, "/refrigeration/0": {}}) is False
def test_rapid_switches_shown_when_vs_href_absent(self):
for key in ("rapid_fridge", "rapid_freezing"):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn is not None
assert desc.exists_fn({}, {"/refrigeration/0": {}}) is True
@@ -201,6 +227,7 @@ class TestFlexZone:
"x.com.samsung.da.supportedOptions": ["KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]"],
}
desc = fridge.FLEX_ZONE.entities[0]
assert desc.exists_fn is not None
assert desc.exists_fn(no_overlap_rep, {}) is False
overlap_rep = {
@@ -250,8 +277,11 @@ class TestPantryZone:
assert fridge.PANTRY_ZONE.href == "/status/pantry/one/vs/0"
def test_write(self):
desc = fridge.PANTRY_ZONE.entities[0]
path, body = desc.write_fn("FDR_WINE", {})
desc = next(e for e in fridge.PANTRY_ZONE.entities if isinstance(e, SelectDesc))
assert desc.write_fn is not None
result = desc.write_fn("FDR_WINE", {})
assert result is not None
path, body = result
assert path == ["status", "pantry", "one", "vs", "0"]
assert body == {"x.com.samsung.da.mode": "FDR_WINE"}
@@ -266,8 +296,13 @@ class TestDefiniteTemperatureCooler:
assert fridge.DEFINITE_TEMPERATURE_COOLER.href == "/temperature/definite/cooler/vs/0"
def test_write(self):
desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0]
path, body = desc.write_fn("3", {})
desc = next(
e for e in fridge.DEFINITE_TEMPERATURE_COOLER.entities if isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("3", {})
assert result is not None
path, body = result
assert path == ["temperature", "definite", "cooler", "vs", "0"]
assert body == {"x.com.samsung.da.definite.desired": "3"}
@@ -304,14 +339,19 @@ class TestKimchiZone:
assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == "/kimchidoors/"
def test_write_derives_path_from_href(self):
desc = fridge.KIMCHI_ZONE.entities[0]
desc = next(e for e in fridge.KIMCHI_ZONE.entities if isinstance(e, SelectDesc))
assert desc.write_fn is not None
write_fn = cast("Callable[..., tuple[list[str], dict] | None]", desc.write_fn)
rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]}
path, body = desc.write_fn("KIMCHI_STORAGE_COLD", rep, href="/status/kimchi/middle/vs/0")
result = write_fn("KIMCHI_STORAGE_COLD", rep, href="/status/kimchi/middle/vs/0")
assert result is not None
path, body = result
assert path == ["status", "kimchi", "middle", "vs", "0"]
assert body == {"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_COLD"}
def test_write_without_href_is_rejected(self):
desc = fridge.KIMCHI_ZONE.entities[0]
desc = next(e for e in fridge.KIMCHI_ZONE.entities if isinstance(e, SelectDesc))
assert desc.write_fn is not None
rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]}
assert desc.write_fn("KIMCHI_STORAGE_COLD", rep) is None
@@ -320,10 +360,12 @@ class TestKimchiZone:
written blind -- this write path is unconfirmed against real
hardware (module docstring above KIMCHI_ZONE), so a bad value here
is a food-safety-adjacent outcome, not just a cosmetic one."""
desc = fridge.KIMCHI_ZONE.entities[0]
desc = next(e for e in fridge.KIMCHI_ZONE.entities if isinstance(e, SelectDesc))
assert desc.write_fn is not None
write_fn = cast("Callable[..., tuple[list[str], dict] | None]", desc.write_fn)
rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]}
assert (
desc.write_fn(
write_fn(
"KIMCHI_STORAGE_WARM",
rep,
href="/status/kimchi/middle/vs/0",
@@ -340,6 +382,7 @@ class TestKimchiZone:
def test_door_reuses_open_state_helper(self):
desc = fridge.KIMCHI_DOOR_GENERIC.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"x.com.samsung.da.openState": "Open"}) is True
assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False
@@ -381,7 +424,7 @@ class TestKimchiZone:
self.commands.append(value)
coordinator = _FakeCoordinator(resources, flatten(bound, resources))
entity = LocalThingsSelect(coordinator, mode_bound)
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), mode_bound)
assert entity.current_option == "kimchi_storage_normal"
assert "kimchi_storage_cold" in entity.options
+134 -33
View File
@@ -7,6 +7,7 @@ from custom_components.localthings.registry.by_type import (
)
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage
@@ -76,16 +77,23 @@ def test_qooker_fixture_resolves_as_microwave_and_has_no_unbound_hrefs():
# ---------------------------------------------------------------------------
def _microwave_setpoint_desc():
return next(e for e in microwave.MICROWAVE_SETPOINT.entities if isinstance(e, NumberDesc))
def test_microwave_setpoint_write_is_read_modify_write():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]}
path, body = desc.write_fn(180, rep)
assert desc.write_fn is not None
result = desc.write_fn(180, rep)
assert result is not None
path, body = result
assert path == ["temperatures", "vs", "0"]
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180"
def test_microwave_setpoint_rmw_preserves_other_item_fields():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
rep = {
"x.com.samsung.da.items": [
{
@@ -94,28 +102,36 @@ def test_microwave_setpoint_rmw_preserves_other_item_fields():
}
]
}
_path, body = desc.write_fn(180, rep)
assert desc.write_fn is not None
result = desc.write_fn(180, rep)
assert result is not None
_path, body = result
item = body["x.com.samsung.da.items"][0]
assert item["x.com.samsung.da.desired"] == "180"
assert item["x.com.samsung.da.current"] == "150"
def test_microwave_setpoint_clamps_to_step():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]}
_, body = desc.write_fn(182, rep) # nearest 5 = 180
assert desc.write_fn is not None
result = desc.write_fn(182, rep) # nearest 5 = 180
assert result is not None
_, body = result
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180"
def test_microwave_setpoint_rejects_out_of_range():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "100"}]}
assert desc.write_fn is not None
assert desc.write_fn(20, rep) is None # below min (40)
assert desc.write_fn(210, rep) is None # above max (200)
def test_microwave_setpoint_rejects_missing_items():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
assert desc.write_fn is not None
assert desc.write_fn(180, {}) is None
@@ -123,9 +139,10 @@ def test_microwave_setpoint_exists_only_for_celsius():
"""No Fahrenheit dump exists for this family (unlike oven.py's, verified
against issue #44) -- the writable setpoint stays hidden rather than
showing unverified bounds under the wrong unit."""
desc = microwave.MICROWAVE_SETPOINT.entities[0]
desc = _microwave_setpoint_desc()
celsius_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Celsius"}]}
fahrenheit_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Fahrenheit"}]}
assert desc.exists_fn is not None
assert desc.exists_fn(celsius_rep, {}) is True
assert desc.exists_fn(fahrenheit_rep, {}) is False
@@ -157,8 +174,12 @@ def test_power_level_handles_missing_value():
# ---------------------------------------------------------------------------
def _microwave_cooking_mode_desc():
return next(e for e in microwave.MICROWAVE_MODE.entities if isinstance(e, SelectDesc))
def test_microwave_mode_options_nonempty():
desc = microwave.MICROWAVE_MODE.entities[0]
desc = _microwave_cooking_mode_desc()
assert callable(desc.options)
options = desc.options({})
assert len(options) > 0
@@ -171,7 +192,7 @@ def test_microwave_mode_options_reads_live_supported_modes():
_MICROWAVE_MODES -- the live supportedModes list is used verbatim when
present, same live-first pattern as oven._oven_mode_options, instead of
offering users modes their own unit doesn't have."""
desc = microwave.MICROWAVE_MODE.entities[0]
desc = _microwave_cooking_mode_desc()
resources = {
"/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"],
@@ -181,14 +202,18 @@ def test_microwave_mode_options_reads_live_supported_modes():
def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0]
path, body = desc.write_fn("MicroWave", {})
desc = _microwave_cooking_mode_desc()
assert desc.write_fn is not None
result = desc.write_fn("MicroWave", {})
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["MicroWave"]
def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0]
desc = _microwave_cooking_mode_desc()
assert desc.write_fn is not None
assert desc.write_fn("SpaghettiMode", {}) is None
@@ -197,9 +222,12 @@ def test_microwave_mode_write_validates_against_live_supported_modes():
list, not the static union-of-all-dumps fallback -- 'AirFryer' is a
valid _MICROWAVE_MODES entry but must still be rejected for a unit
whose own supportedModes doesn't include it."""
desc = microwave.MICROWAVE_MODE.entities[0]
desc = _microwave_cooking_mode_desc()
rep = {"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"]}
path, body = desc.write_fn("MicroWave", rep)
assert desc.write_fn is not None
result = desc.write_fn("MicroWave", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["MicroWave"]
assert desc.write_fn("AirFryer", rep) is None
@@ -211,9 +239,16 @@ def test_microwave_mode_write_validates_against_live_supported_modes():
def test_sound_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "sound")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "sound" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["Sound_On"]}
path, body = desc.write_fn("Off", rep)
assert desc.write_fn is not None
result = desc.write_fn("Off", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Sound_Off"]}
@@ -221,30 +256,52 @@ def test_sound_write_is_single_token():
def test_lamp_gated_absent_when_no_lamp_option():
"""Issue #121's combi dump has no 'Lamp_*' token at all -- unlike
oven.py's lamp switch (assumed universal), this one self-gates off."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "lamp" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is False
def test_lamp_gated_present_when_lamp_option_reported():
"""Issue #137's plain microwave reports 'Lamp_Off'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "lamp" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["Lamp_Off", "Sound_On"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True
def test_lamp_write_is_single_token():
"""issue #152: the device has never been observed accepting 'On' --
only 'High'/'Off' -- so the switch's "on" write uses 'High'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "lamp" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["Lamp_Off"]}
path, body = desc.write_fn("On", rep)
assert desc.write_fn is not None
result = desc.write_fn("On", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Lamp_High"]}
def test_lamp_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "lamp" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
assert desc.write_fn("On", {}) is None
@@ -267,14 +324,24 @@ def test_lamp_reads_any_non_off_level_as_true():
def test_filter_remind_gated_absent_when_no_option():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "filter_remind" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is False
def test_filter_remind_gated_present_when_option_reported():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "filter_remind" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["FilterRemind_Off"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True
@@ -285,27 +352,49 @@ def test_filter_remind_reads_on_off():
def test_filter_remind_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "filter_remind" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["FilterRemind_Off"]}
path, body = desc.write_fn("On", rep)
assert desc.write_fn is not None
result = desc.write_fn("On", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["FilterRemind_On"]}
def test_filter_remind_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "filter_remind" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
assert desc.write_fn("On", {}) is None
def test_remind_beep_gated_absent_when_no_option():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "remind_beep" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is False
def test_remind_beep_gated_present_when_option_reported():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "remind_beep" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["RemindBeep_On"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True
@@ -316,13 +405,25 @@ def test_remind_beep_reads_on_off():
def test_remind_beep_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "remind_beep" and isinstance(e, SwitchDesc)
)
rep = {"x.com.samsung.da.options": ["RemindBeep_On"]}
path, body = desc.write_fn("Off", rep)
assert desc.write_fn is not None
result = desc.write_fn("Off", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["RemindBeep_Off"]}
def test_remind_beep_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep")
desc = next(
e
for e in microwave.MICROWAVE_MODE.entities
if e.key == "remind_beep" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
assert desc.write_fn("On", {}) is None
+94 -31
View File
@@ -3,6 +3,11 @@
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import oven
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import (
NumberDesc,
SelectDesc,
SwitchDesc,
)
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage (issue #55)
@@ -33,15 +38,21 @@ def test_oven_fixture_resolves_and_has_no_unbound_hrefs():
# ---------------------------------------------------------------------------
def _oven_setpoint_desc():
return next(e for e in oven.OVEN_SETPOINT.entities if isinstance(e, NumberDesc))
def test_oven_setpoint_write_is_read_modify_write():
cap = oven.OVEN_SETPOINT
desc = cap.entities[0]
desc = _oven_setpoint_desc()
rep = {
"x.com.samsung.da.items": [
{"x.com.samsung.da.id": "Target", "x.com.samsung.da.temperature": 0}
]
}
path, body = desc.write_fn(200, rep)
assert desc.write_fn is not None
result = desc.write_fn(200, rep)
assert result is not None
path, body = result
assert path[-1] == "0" # writes back to the resource
# the produced body preserves the items-array shape the oven expects
assert "x.com.samsung.da.items" in body or "x.com.samsung.da.temperature" in str(body)
@@ -49,7 +60,7 @@ def test_oven_setpoint_write_is_read_modify_write():
def test_oven_setpoint_rmw_preserves_other_item_fields():
"""RMW must not drop sibling fields (e.g. x.com.samsung.da.current)."""
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
rep = {
"x.com.samsung.da.items": [
{
@@ -58,7 +69,10 @@ def test_oven_setpoint_rmw_preserves_other_item_fields():
}
]
}
_path, body = desc.write_fn(200, rep)
assert desc.write_fn is not None
result = desc.write_fn(200, rep)
assert result is not None
_path, body = result
item = body["x.com.samsung.da.items"][0]
assert item["x.com.samsung.da.desired"] == "200"
# sibling field preserved
@@ -67,22 +81,27 @@ def test_oven_setpoint_rmw_preserves_other_item_fields():
def test_oven_setpoint_clamps_to_step():
"""Setpoint must be a multiple of SETPOINT_STEP_C."""
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]}
_, body = desc.write_fn(202, rep)
assert desc.write_fn is not None
result = desc.write_fn(202, rep)
assert result is not None
_, body = result
# 202 → nearest 5 = 200
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "200"
def test_oven_setpoint_rejects_out_of_range():
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "100"}]}
assert desc.write_fn is not None
assert desc.write_fn(10, rep) is None # below min (30)
assert desc.write_fn(300, rep) is None # above max (270)
def test_oven_setpoint_rejects_missing_items():
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
assert desc.write_fn is not None
assert desc.write_fn(200, {}) is None
@@ -105,24 +124,31 @@ def _fahrenheit_rep(desired="0"):
def test_oven_setpoint_write_uses_fahrenheit_bounds():
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
rep = _fahrenheit_rep()
# 350 is within F bounds (175-550) but above the C max (270) --
# confirms the write path isn't silently still clamping to Celsius.
_, body = desc.write_fn(350, rep)
assert desc.write_fn is not None
result = desc.write_fn(350, rep)
assert result is not None
_, body = result
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "350"
def test_oven_setpoint_rejects_out_of_range_fahrenheit():
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
rep = _fahrenheit_rep()
assert desc.write_fn is not None
assert desc.write_fn(100, rep) is None # below F min (175)
assert desc.write_fn(600, rep) is None # above F max (550)
def test_oven_setpoint_native_bounds_track_live_unit():
desc = oven.OVEN_SETPOINT.entities[0]
desc = _oven_setpoint_desc()
celsius_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Celsius"}]}
assert desc.native_min_fn is not None
assert desc.native_max_fn is not None
assert desc.step_fn is not None
assert desc.native_min_fn(celsius_rep) == 30.0
assert desc.native_max_fn(celsius_rep) == 270.0
fahrenheit_rep = _fahrenheit_rep()
@@ -136,21 +162,25 @@ def test_oven_setpoint_native_bounds_track_live_unit():
# ---------------------------------------------------------------------------
def _oven_mode_desc():
return next(e for e in oven.OVEN_MODE.entities if isinstance(e, SelectDesc))
def test_oven_mode_options_nonempty():
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
assert callable(desc.options)
assert len(desc.options({})) > 0
def test_oven_mode_options_falls_back_when_no_live_supported_modes():
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
assert desc.options({}) == list(oven._OVEN_MODES)
def test_oven_mode_options_reads_live_supported_modes():
"""issue #138: the device's own supportedModes list is used verbatim
when present, instead of the static _OVEN_MODES guess."""
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
resources = {
"/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["Bake", "AirFryer", "SelfClean"],
@@ -160,15 +190,19 @@ def test_oven_mode_options_reads_live_supported_modes():
def test_oven_mode_write_round_trips():
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
valid_mode = desc.options({})[1] # e.g. 'Bake'
path, body = desc.write_fn(valid_mode, {})
assert desc.write_fn is not None
result = desc.write_fn(valid_mode, {})
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == [valid_mode]
def test_oven_mode_rejects_unknown():
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
assert desc.write_fn is not None
assert desc.write_fn("SpaghettiMode", {}) is None
@@ -177,9 +211,12 @@ def test_oven_mode_write_validates_against_live_supported_modes():
list, not the static fallback -- issue #138's AirFryer/SelfClean/etc.
are accepted, and a mode outside the device's own list is rejected even
if some other model's static guess would have allowed it."""
desc = oven.OVEN_MODE.entities[0]
desc = _oven_mode_desc()
rep = {"x.com.samsung.da.supportedModes": ["Bake", "AirFryer", "SelfClean"]}
path, body = desc.write_fn("AirFryer", rep)
assert desc.write_fn is not None
result = desc.write_fn("AirFryer", rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["AirFryer"]
assert desc.write_fn("FrozenPizzaPlus", rep) is None
@@ -205,20 +242,29 @@ def _mode_rep(*extra_opts):
def test_lamp_write_is_single_token():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp")
path, body = desc.write_fn("On", _mode_rep())
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp" and isinstance(e, SwitchDesc))
assert desc.write_fn is not None
result = desc.write_fn("On", _mode_rep())
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["UpperLamp_On"]}
def test_lamp_write_requires_existing_options():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp")
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp" and isinstance(e, SwitchDesc))
assert desc.write_fn is not None
assert desc.write_fn("On", {}) is None
def test_sound_write_is_single_token():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "sound")
_path, body = desc.write_fn("Off", _mode_rep())
desc = next(
e for e in oven.OVEN_MODE.entities if e.key == "sound" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("Off", _mode_rep())
assert result is not None
_path, body = result
assert body == {"x.com.samsung.da.options": ["Sound_Off"]}
@@ -226,8 +272,13 @@ def test_natural_steam_write_is_single_token():
"""NaturalSteam's slot may be absent from the live rep until first
write -- the single-token write covers both the insert and replace case
identically, since the device merges by prefix either way."""
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "natural_steam")
_path, body = desc.write_fn("On", _mode_rep()) # no NaturalSteam_* in rep
desc = next(
e for e in oven.OVEN_MODE.entities if e.key == "natural_steam" and isinstance(e, SwitchDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("On", _mode_rep()) # no NaturalSteam_* in rep
assert result is not None
_path, body = result
assert body == {"x.com.samsung.da.options": ["NaturalSteam_On"]}
@@ -254,14 +305,26 @@ def test_cycle_active_false_when_idle():
def test_cook_time_write_produces_hms():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cook_time")
path, body = desc.write_fn(90, {})
desc = next(
e
for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == "cook_time" and isinstance(e, NumberDesc)
)
assert desc.write_fn is not None
result = desc.write_fn(90, {})
assert result is not None
path, body = result
assert path == ["operational", "state", "vs", "0"]
assert body["x.com.samsung.da.operationTime"] == "01:30:00"
assert body["x.com.samsung.da.remainingTime"] == "01:30:00"
def test_cook_time_rejects_out_of_range():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cook_time")
desc = next(
e
for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == "cook_time" and isinstance(e, NumberDesc)
)
assert desc.write_fn is not None
assert desc.write_fn(-1, {}) is None
assert desc.write_fn(1440, {}) is None
+3 -2
View File
@@ -1,7 +1,8 @@
"""HA fan-entity mapping tests for the range hood."""
from typing import ClassVar
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import microwave, range_hood
from custom_components.localthings.registry.discovery import discover
@@ -39,7 +40,7 @@ def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(
coordinator or _FakeCoordinator(resources),
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
fan_bound,
)
+9 -2
View File
@@ -5,10 +5,13 @@ hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/
BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own
/mode/vs/0 supportedModes rather than needing them hardcoded."""
from typing import cast
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_resources
from custom_components.localthings.registry.capabilities import oven
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device
@@ -64,7 +67,9 @@ def test_oven_mode_accepts_this_devices_supported_modes():
those modes added to any Python list."""
_, resources = _range()
live_rep = resources["/mode/vs/0"]
desc = oven.OVEN_MODE.entities[0]
desc = cast(SelectDesc, oven.OVEN_MODE.entities[0])
assert desc.options is not None
assert desc.write_fn is not None
assert desc.options(resources) == live_rep["x.com.samsung.da.supportedModes"]
for mode in (
"ConvectionRoast",
@@ -75,6 +80,8 @@ def test_oven_mode_accepts_this_devices_supported_modes():
"SelfClean",
"SteamClean",
):
path, body = desc.write_fn(mode, live_rep)
result = desc.write_fn(mode, live_rep)
assert result is not None
path, body = result
assert path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == [mode]
+3 -1
View File
@@ -7,9 +7,11 @@ capable or not -- exercises it automatically.
"""
from collections import Counter
from typing import cast
import pytest
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.entity import _is_included
from custom_components.localthings.registry.adapter import _key
from custom_components.localthings.registry.entities import PLATFORM_OF
@@ -57,7 +59,7 @@ def test_key_is_unique_across_all_bound_entities(name):
oic_res,
seeds,
)
coordinator = _FakeCoordinator(full_resources, materialized)
coordinator = cast(LocalThingsCoordinator, _FakeCoordinator(full_resources, materialized))
included = [b for b in bound if _is_included(b, coordinator)]
keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included]
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
+5 -2
View File
@@ -1,6 +1,6 @@
"""HA water_heater-entity mapping tests for the EHS DHW loop."""
from typing import ClassVar
from typing import ClassVar, cast
from homeassistant.components.water_heater import (
STATE_ECO,
@@ -11,6 +11,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import STATE_OFF, UnitOfTemperature
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.by_type import ehs
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import WaterHeaterDesc
@@ -47,7 +48,9 @@ def _entity(resources, coordinator=None):
ehs.REGISTRY.pattern_capabilities,
)
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc))
return LocalThingsWaterHeater(coordinator or _FakeCoordinator(resources), water_heater_bound)
return LocalThingsWaterHeater(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), water_heater_bound
)
def test_current_operation_reads_eco_when_on():