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.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model 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.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc, SwitchDesc
from tests.conftest import _load_device from tests.conftest import _load_device
@@ -53,9 +54,12 @@ def test_buzzer_sound_present_and_writable():
desc = next( desc = next(
e e
for e in air_dresser.REGISTRY.capabilities["/buzzersound/vs/0"][0].entities 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 path == ["buzzersound", "vs", "0"]
assert body == {"setBuzzerSound": "Off"} assert body == {"setBuzzerSound": "Off"}
@@ -75,8 +79,11 @@ def test_sanitize_present_and_toggles():
desc = next( desc = next(
e e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities 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 path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"} 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.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model 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.discovery import discover
from custom_components.localthings.registry.entities import SwitchDesc
from tests.conftest import _load_device from tests.conftest import _load_device
@@ -77,9 +78,12 @@ def test_sanitize_present_and_toggles():
desc = next( desc = next(
e e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities 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 path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"} assert body == {"x.com.samsung.da.sanitize": "On"}
assert desc.write_fn("Sparkle", {}) is None 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. 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.climate import LocalThingsClimate
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import ( from custom_components.localthings.registry.by_type import (
airconditioner, airconditioner,
@@ -82,7 +83,7 @@ def _climate(resources, coordinator=None):
bound, _ = _discover(resources) bound, _ = _discover(resources)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate( return LocalThingsClimate(
coordinator or _FakeCoordinator(resources), cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
climate_bound, 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.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover 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 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 /power/0 is absent on most boards and a non-authoritative mirror where
present; /temperature/desired/0 is only written via the temperature_ocf present; /temperature/desired/0 is only written via the temperature_ocf
kind, on boards that have the OCF pair).""" 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", 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(("power", False), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "Off"})
assert write(("mode", "Heat"), {}) == ( assert write(("mode", "Heat"), {}) == (
@@ -285,13 +292,17 @@ def test_caww_tp2_sac_installationinfo_is_ignored():
def test_mute_once_write_target(): 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("On", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "On"})
assert write("Off", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "Off"}) assert write("Off", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "Off"})
def test_display_light_write_target(): 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("On", {}) == (["light", "vs", "0"], {"mode": "On"})
assert write("Off", {}) == (["light", "vs", "0"], {"mode": "Off"}) 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(): def test_mode_options_display_light_write_is_inverted_single_token():
"""Turning the lamp ON writes the inverted 'Light_Off' token as a """Turning the lamp ON writes the inverted 'Light_Off' token as a
single-element options list (single-token merge); OFF writes 'Light_On'.""" 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", {}) == ( assert sw.write_fn("On", {}) == (
["mode", "vs", "0"], ["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Light_Off"]}, {"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 CURRENT_TEMPERATURE when a device (like this one) reports both
/temperature/current/0 and /temperatures/vs/0.""" /temperature/current/0 and /temperatures/vs/0."""
match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn
assert match is not None
assert match({}, {"/temperature/current/0": {}}) is False assert match({}, {"/temperature/current/0": {}}) is False
assert match({}, {}) is True assert match({}, {}) is True
def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
desc = airconditioner.HUMIDITY.entities[0] 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"} rep = {"x.com.samsung.da.humidity": "0", "x.com.samsung.da.fivepercentHumidity": "42"}
assert desc.rep_fn(rep) == 42.0 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 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.""" is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
desc = airconditioner.HUMIDITY.entities[0] 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": "51"}) == 51.0
assert desc.rep_fn({"x.com.samsung.da.humidity": "0"}) is None assert desc.rep_fn({"x.com.samsung.da.humidity": "0"}) is None
assert desc.rep_fn({}) 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 fallback field collapses 0 -- fivepercentHumidity's 0 is a real
reading.""" reading."""
desc = airconditioner.HUMIDITY.entities[0] desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn is not None
assert desc.rep_fn({"x.com.samsung.da.fivepercentHumidity": "0"}) == 0.0 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(): def test_lnx_rac_heatpump_absence_power_saving_write_target():
write = next( desc = next(
e e
for e in airconditioner.ABSENCE_POWER_SAVING.entities for e in airconditioner.ABSENCE_POWER_SAVING.entities
if e.key == "absence_power_saving_active" if e.key == "absence_power_saving_active" and isinstance(e, SwitchDesc)
).write_fn )
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "On"}) assert write("On", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "On"})
assert write("Off", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "Off"}) assert write("Off", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "Off"})
def test_lnx_rac_heatpump_motion_detect_wind_write_target(): def test_lnx_rac_heatpump_motion_detect_wind_write_target():
write = next( desc = next(
e e
for e in airconditioner.MOTION_DETECT_WIND.entities for e in airconditioner.MOTION_DETECT_WIND.entities
if e.key == "motion_detect_wind_active" if e.key == "motion_detect_wind_active" and isinstance(e, SwitchDesc)
).write_fn )
assert desc.write_fn is not None
write = desc.write_fn
assert write("On", {}) == ( assert write("On", {}) == (
["option", "motiondetectwind", "stateful", "vs", "0"], ["option", "motiondetectwind", "stateful", "vs", "0"],
{"status": "On"}, {"status": "On"},
@@ -710,6 +734,7 @@ def test_tropical_night_absent_when_no_sleep_token():
reg = for_device_by_model( reg = for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] 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) state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert "tropical_night_mode" not in state assert "tropical_night_mode" not in state
@@ -723,6 +748,7 @@ def test_tropical_night_state_levels_across_fixtures():
r = for_device_by_model( r = for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] 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( return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get(
"tropical_night_mode" "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 """filterUsage is a lifetime hour counter (41 of 500) that resets on
filter replacement -- total_increasing, not measurement. Unit comes from filter replacement -- total_increasing, not measurement. Unit comes from
filterCapacityUnit via unit_fn, not a hardcoded 'h'.""" 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(41) == 41 assert desc.value_fn(41) == 41
assert desc.value_fn(None) is None assert desc.value_fn(None) is None
assert desc.value_fn("not-a-number") is None assert desc.value_fn("not-a-number") is None
assert desc.device_class == "duration" assert desc.device_class == "duration"
assert desc.state_class == "total_increasing" 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": "Hour"}) == "h"
assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Minute"}) == "min" assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Minute"}) == "min"
assert desc.unit_fn({}) == "h" # static fallback when the field is absent 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") desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_threshold")
assert isinstance(desc, SelectDesc) assert isinstance(desc, SelectDesc)
assert desc.options_field == "x.com.samsung.da.supportedFilterDesiredUsage" assert desc.options_field == "x.com.samsung.da.supportedFilterDesiredUsage"
assert desc.exists_fn is not None
assert ( assert (
desc.exists_fn( desc.exists_fn(
{"x.com.samsung.da.supportedFilterDesiredUsage": ["180", "300", "500", "700"]}, {} {"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(500) == "500"
assert desc.value_fn(None) is None assert desc.value_fn(None) is None
# Write POSTs the selected option as the scalar field. # Write POSTs the selected option as the scalar field.
assert desc.write_fn is not None
assert desc.write_fn("700", {}) == ( assert desc.write_fn("700", {}) == (
["filter", "airdustfilter", "vs", "0"], ["filter", "airdustfilter", "vs", "0"],
{"x.com.samsung.da.filterDesiredUsage": "700"}, {"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. second numeric scale.
""" """
from typing import ClassVar from typing import ClassVar, cast
from custom_components.localthings.climate import ( from custom_components.localthings.climate import (
_DEVICE_TO_FAN, _DEVICE_TO_FAN,
LocalThingsClimate, LocalThingsClimate,
_wind_strength_label, _wind_strength_label,
) )
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry import by_type from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc from custom_components.localthings.registry.entities import ClimateDesc
@@ -53,9 +54,12 @@ def _climate(resources, coordinator=None):
reg = by_type.for_device_by_model( reg = by_type.for_device_by_model(
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] 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) bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) 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(): 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.""" """Tests for samsung_appliance/registry/by_type."""
from typing import cast
import pytest import pytest
from custom_components.localthings.registry.by_type import ( from custom_components.localthings.registry.by_type import (
@@ -110,7 +112,10 @@ class TestBoardTokens:
from custom_components.localthings.registry.by_type import _board_tokens from custom_components.localthings.registry.by_type import _board_tokens
assert _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: class TestBoardTokenTable:
+1 -1
View File
@@ -14,7 +14,7 @@ def test_capability_defaults():
def test_capability_is_frozen(): def test_capability_is_frozen():
c = Capability(href="/kidslock/vs/0", entities=()) c = Capability(href="/kidslock/vs/0", entities=())
try: try:
c.href = "/other/vs/0" setattr(c, "href", "/other/vs/0")
except Exception: except Exception:
return return
raise AssertionError("expected frozen dataclass") 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)) 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(True) is True
assert desc.value_fn(False) is False 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 path == ["power", "0"]
assert body == {"value": True} 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): def test_vs_fallback_binds_only_when_generic_absent(self):
assert common.POWER_VS_FALLBACK.href == "/power/vs/0" 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/vs/0": {}}) is True
assert common.POWER_VS_FALLBACK.match_fn({}, {"/power/0": {}, "/power/vs/0": {}}) is False 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)) 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("On") is True
assert desc.value_fn("Off") is False 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 path == ["power", "vs", "0"]
assert body == {"x.com.samsung.da.power": "On"} assert body == {"x.com.samsung.da.power": "On"}
@@ -160,6 +169,8 @@ class TestPowerFallback:
"/power/0": {"value": True}, "/power/0": {"value": True},
"/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "false"}, "/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 switch.exists_fn(resources["/power/0"], resources) is False
assert sensor.exists_fn(resources["/power/0"], resources) is True assert sensor.exists_fn(resources["/power/0"], resources) is True
@@ -170,6 +181,8 @@ class TestPowerFallback:
"/power/0": {"value": True}, "/power/0": {"value": True},
"/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "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 switch.exists_fn(resources["/power/0"], resources) is True
assert sensor.exists_fn(resources["/power/0"], resources) is False 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)) 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)) sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc))
resources = {"/power/0": {"value": True}} 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 switch.exists_fn(resources["/power/0"], resources) is True
assert sensor.exists_fn(resources["/power/0"], resources) is False 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 assert desc.value_fn(True) is False # value=True -> Off=Locked
def test_vs_fallback_gated(self): 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/vs/0": {}}) is True
assert ( assert (
common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {"/kidslock/0": {}, "/kidslock/vs/0": {}}) 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 assert desc.value_fn(False) is False
def test_vs_fallback_gated(self): 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({}, {"/remotectrl/vs/0": {}}) is True
assert ( assert (
common.REMOTE_CONTROL_VS_FALLBACK.match_fn( common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
@@ -351,18 +368,22 @@ class TestEnergyMeter:
def test_power_watts_hidden_for_dead_sentinel(self): def test_power_watts_hidden_for_dead_sentinel(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") 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 assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False
def test_power_watts_shown_for_real_value(self): def test_power_watts_shown_for_real_value(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") 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 assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "150"}, {}) is True
def test_energy_kwh_hidden_when_cumulative_power_absent(self): 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") 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 assert kwh.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False
def test_energy_kwh_shown_when_present(self): def test_energy_kwh_shown_when_present(self):
kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh") 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 assert kwh.exists_fn({"x.com.samsung.da.cumulativePower": "58900"}, {}) is True
def test_both_entities_included_on_true_stub(self): 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.""" 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") 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") 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"} stub = {"href": "/energy/consumption/vs/0"}
assert pw.exists_fn(stub, {}) is True assert pw.exists_fn(stub, {}) is True
assert kwh.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).""" 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") 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") 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 pw.exists_fn({}, {}) is False
assert kwh.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 """A populated rep that lacks instantaneousPower must not spawn a
phantom power sensor (the exists_fn replaces the field-presence gate).""" 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") 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 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 __future__ import annotations
from typing import cast
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import cbor2 import cbor2
import pytest import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from custom_components.localthings.const import ( from custom_components.localthings.const import (
CONF_HOST, CONF_HOST,
@@ -31,6 +33,7 @@ from custom_components.localthings.const import (
from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities import laundry from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write 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.discovery import BoundEntity
from custom_components.localthings.registry.entities import ClimateDesc from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.subdevices import Subdevice from custom_components.localthings.registry.subdevices import Subdevice
@@ -65,7 +68,7 @@ def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry.add_to_hass(hass) entry.add_to_hass(hass)
coord = LocalThingsCoordinator(hass, entry) coord = LocalThingsCoordinator(hass, entry)
coord.async_request_refresh = AsyncMock() coord.async_request_refresh = AsyncMock()
coord._session = _FakeSendSession() coord._session = cast(DtlsCoapSession, _FakeSendSession())
return coord 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") 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") 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") 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") 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: def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
desc = ClimateDesc(key="climate", translation_key="airconditioner", write_fn=_climate_write) 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: 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.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device 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 the shipped dryer_cycle_table_03 translations, consistent with
washer/dishwasher.""" washer/dishwasher."""
assert dryer.DRYER_COURSE.href == "/course/vs/0" 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) assert callable(desc.translation_key)
table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}} 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(table_03) == "dryer_cycle_table_03"
assert desc.translation_key({}) == "cycle" assert desc.translation_key({}) == "cycle"
assert desc.options is laundry.cycle_options assert desc.options is laundry.cycle_options
rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]} rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]}
assert desc.rep_fn is not None
assert desc.rep_fn(rep) == "16" assert desc.rep_fn(rep) == "16"
+4 -1
View File
@@ -1,5 +1,8 @@
"""Tests for translated entity naming and dynamic instance placeholders.""" """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.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity 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, key_override=key_override,
instance_name=instance_name, instance_name=instance_name,
) )
return LocalThingsEntity(_FakeCoordinator(), bound) return LocalThingsEntity(cast(LocalThingsCoordinator, _FakeCoordinator()), bound)
def test_descriptor_key_is_the_default_translation_key(): def test_descriptor_key_is_the_default_translation_key():
+61 -18
View File
@@ -1,8 +1,15 @@
"""Tests for fridge-specific capabilities.""" """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.capabilities import fridge
from custom_components.localthings.registry.entities import (
NumberDesc,
SelectDesc,
SensorDesc,
)
class TestTempCurrentGeneric: class TestTempCurrentGeneric:
@@ -11,15 +18,18 @@ class TestTempCurrentGeneric:
resource in Celsius.""" resource in Celsius."""
def test_unit_reads_celsius(self): 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" assert desc.unit_fn({"temperature": 3.0, "units": "C"}) == "°C"
def test_unit_reads_fahrenheit(self): 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" assert desc.unit_fn({"temperature": 5.0, "units": "F"}) == "°F"
def test_unit_defaults_to_fahrenheit_when_missing(self): 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" assert desc.unit_fn({"temperature": 5.0}) == "°F"
@@ -32,23 +42,27 @@ class TestDoorGeneric:
def test_reads_bare_open_state(self): def test_reads_bare_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0] 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": "Open"}) is True
assert desc.rep_fn({"openState": "Close"}) is False assert desc.rep_fn({"openState": "Close"}) is False
def test_reads_vendor_prefixed_open_state(self): def test_reads_vendor_prefixed_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0] 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": "Open"}) is True
assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False
def test_prefers_bare_field_when_both_present(self): def test_prefers_bare_field_when_both_present(self):
desc = fridge.DOOR_GENERIC.entities[0] desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn is not None
rep = {"openState": "Open", "x.com.samsung.da.openState": "Close"} rep = {"openState": "Open", "x.com.samsung.da.openState": "Close"}
assert desc.rep_fn(rep) is True assert desc.rep_fn(rep) is True
class TestTempSetpointGeneric: class TestTempSetpointGeneric:
def test_unit_reads_celsius(self): 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" assert desc.unit_fn({"temperature": -19.0, "units": "C"}) == "°C"
@@ -58,8 +72,11 @@ class TestTemperaturesFallback:
def test_freezer_unit_celsius(self): def test_freezer_unit_celsius(self):
desc = next( 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 = { rep = {
"x.com.samsung.da.items": [ "x.com.samsung.da.items": [
{ {
@@ -79,8 +96,11 @@ class TestTemperaturesFallback:
def test_fridge_unit_fahrenheit(self): def test_fridge_unit_fahrenheit(self):
desc = next( 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 = { rep = {
"x.com.samsung.da.items": [ "x.com.samsung.da.items": [
{ {
@@ -94,8 +114,11 @@ class TestTemperaturesFallback:
def test_unit_defaults_to_fahrenheit_when_item_missing(self): def test_unit_defaults_to_fahrenheit_when_item_missing(self):
desc = next( 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" 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") 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(True) is True
assert desc.value_fn(False) is False 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": {}}) is True
assert desc.exists_fn({}, {"/refrigeration/0": {}, "/defrost/block/vs/0": {}}) is False assert desc.exists_fn({}, {"/refrigeration/0": {}, "/defrost/block/vs/0": {}}) is False
def test_rapid_switches_hidden_when_vs_href_present(self): def test_rapid_switches_hidden_when_vs_href_present(self):
for key in ("rapid_fridge", "rapid_freezing"): for key in ("rapid_fridge", "rapid_freezing"):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) 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 assert desc.exists_fn({}, {"/refrigeration/vs/0": {}, "/refrigeration/0": {}}) is False
def test_rapid_switches_shown_when_vs_href_absent(self): def test_rapid_switches_shown_when_vs_href_absent(self):
for key in ("rapid_fridge", "rapid_freezing"): for key in ("rapid_fridge", "rapid_freezing"):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) 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 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]"], "x.com.samsung.da.supportedOptions": ["KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]"],
} }
desc = fridge.FLEX_ZONE.entities[0] desc = fridge.FLEX_ZONE.entities[0]
assert desc.exists_fn is not None
assert desc.exists_fn(no_overlap_rep, {}) is False assert desc.exists_fn(no_overlap_rep, {}) is False
overlap_rep = { overlap_rep = {
@@ -250,8 +277,11 @@ class TestPantryZone:
assert fridge.PANTRY_ZONE.href == "/status/pantry/one/vs/0" assert fridge.PANTRY_ZONE.href == "/status/pantry/one/vs/0"
def test_write(self): def test_write(self):
desc = fridge.PANTRY_ZONE.entities[0] desc = next(e for e in fridge.PANTRY_ZONE.entities if isinstance(e, SelectDesc))
path, body = desc.write_fn("FDR_WINE", {}) 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 path == ["status", "pantry", "one", "vs", "0"]
assert body == {"x.com.samsung.da.mode": "FDR_WINE"} 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" assert fridge.DEFINITE_TEMPERATURE_COOLER.href == "/temperature/definite/cooler/vs/0"
def test_write(self): def test_write(self):
desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0] desc = next(
path, body = desc.write_fn("3", {}) 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 path == ["temperature", "definite", "cooler", "vs", "0"]
assert body == {"x.com.samsung.da.definite.desired": "3"} assert body == {"x.com.samsung.da.definite.desired": "3"}
@@ -304,14 +339,19 @@ class TestKimchiZone:
assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == "/kimchidoors/" assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == "/kimchidoors/"
def test_write_derives_path_from_href(self): 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"]} 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 path == ["status", "kimchi", "middle", "vs", "0"]
assert body == {"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_COLD"} assert body == {"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_COLD"}
def test_write_without_href_is_rejected(self): 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"]} rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]}
assert desc.write_fn("KIMCHI_STORAGE_COLD", rep) is None 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 written blind -- this write path is unconfirmed against real
hardware (module docstring above KIMCHI_ZONE), so a bad value here hardware (module docstring above KIMCHI_ZONE), so a bad value here
is a food-safety-adjacent outcome, not just a cosmetic one.""" 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"]} rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]}
assert ( assert (
desc.write_fn( write_fn(
"KIMCHI_STORAGE_WARM", "KIMCHI_STORAGE_WARM",
rep, rep,
href="/status/kimchi/middle/vs/0", href="/status/kimchi/middle/vs/0",
@@ -340,6 +382,7 @@ class TestKimchiZone:
def test_door_reuses_open_state_helper(self): def test_door_reuses_open_state_helper(self):
desc = fridge.KIMCHI_DOOR_GENERIC.entities[0] 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": "Open"}) is True
assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False
@@ -381,7 +424,7 @@ class TestKimchiZone:
self.commands.append(value) self.commands.append(value)
coordinator = _FakeCoordinator(resources, flatten(bound, resources)) 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 entity.current_option == "kimchi_storage_normal"
assert "kimchi_storage_cold" in entity.options 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.capabilities import microwave
from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, SwitchDesc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage # 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(): 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"}]} 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 path == ["temperatures", "vs", "0"]
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180" assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180"
def test_microwave_setpoint_rmw_preserves_other_item_fields(): def test_microwave_setpoint_rmw_preserves_other_item_fields():
desc = microwave.MICROWAVE_SETPOINT.entities[0] desc = _microwave_setpoint_desc()
rep = { rep = {
"x.com.samsung.da.items": [ "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] item = body["x.com.samsung.da.items"][0]
assert item["x.com.samsung.da.desired"] == "180" assert item["x.com.samsung.da.desired"] == "180"
assert item["x.com.samsung.da.current"] == "150" assert item["x.com.samsung.da.current"] == "150"
def test_microwave_setpoint_clamps_to_step(): 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"}]} 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" assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180"
def test_microwave_setpoint_rejects_out_of_range(): 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"}]} 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(20, rep) is None # below min (40)
assert desc.write_fn(210, rep) is None # above max (200) assert desc.write_fn(210, rep) is None # above max (200)
def test_microwave_setpoint_rejects_missing_items(): 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 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 """No Fahrenheit dump exists for this family (unlike oven.py's, verified
against issue #44) -- the writable setpoint stays hidden rather than against issue #44) -- the writable setpoint stays hidden rather than
showing unverified bounds under the wrong unit.""" 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"}]} 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"}]} 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(celsius_rep, {}) is True
assert desc.exists_fn(fahrenheit_rep, {}) is False 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(): def test_microwave_mode_options_nonempty():
desc = microwave.MICROWAVE_MODE.entities[0] desc = _microwave_cooking_mode_desc()
assert callable(desc.options) assert callable(desc.options)
options = desc.options({}) options = desc.options({})
assert len(options) > 0 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 _MICROWAVE_MODES -- the live supportedModes list is used verbatim when
present, same live-first pattern as oven._oven_mode_options, instead of present, same live-first pattern as oven._oven_mode_options, instead of
offering users modes their own unit doesn't have.""" offering users modes their own unit doesn't have."""
desc = microwave.MICROWAVE_MODE.entities[0] desc = _microwave_cooking_mode_desc()
resources = { resources = {
"/mode/vs/0": { "/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"], "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(): def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0] desc = _microwave_cooking_mode_desc()
path, body = desc.write_fn("MicroWave", {}) 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 path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["MicroWave"] assert body["x.com.samsung.da.modes"] == ["MicroWave"]
def test_microwave_mode_rejects_unknown(): 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 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 list, not the static union-of-all-dumps fallback -- 'AirFryer' is a
valid _MICROWAVE_MODES entry but must still be rejected for a unit valid _MICROWAVE_MODES entry but must still be rejected for a unit
whose own supportedModes doesn't include it.""" 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"]} 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 path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["MicroWave"] assert body["x.com.samsung.da.modes"] == ["MicroWave"]
assert desc.write_fn("AirFryer", rep) is None 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(): 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"]} 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 path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Sound_Off"]} 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(): def test_lamp_gated_absent_when_no_lamp_option():
"""Issue #121's combi dump has no 'Lamp_*' token at all -- unlike """Issue #121's combi dump has no 'Lamp_*' token at all -- unlike
oven.py's lamp switch (assumed universal), this one self-gates off.""" 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"]} 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 assert desc.exists_fn(rep, {}) is False
def test_lamp_gated_present_when_lamp_option_reported(): def test_lamp_gated_present_when_lamp_option_reported():
"""Issue #137's plain microwave reports 'Lamp_Off'.""" """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"]} rep = {"x.com.samsung.da.options": ["Lamp_Off", "Sound_On"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True assert desc.exists_fn(rep, {}) is True
def test_lamp_write_is_single_token(): def test_lamp_write_is_single_token():
"""issue #152: the device has never been observed accepting 'On' -- """issue #152: the device has never been observed accepting 'On' --
only 'High'/'Off' -- so the switch's "on" write uses 'High'.""" 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"]} 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 path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Lamp_High"]} assert body == {"x.com.samsung.da.options": ["Lamp_High"]}
def test_lamp_write_requires_existing_options(): 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 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(): 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"]} 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 assert desc.exists_fn(rep, {}) is False
def test_filter_remind_gated_present_when_option_reported(): 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"]} rep = {"x.com.samsung.da.options": ["FilterRemind_Off"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True 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(): 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"]} 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 path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["FilterRemind_On"]} assert body == {"x.com.samsung.da.options": ["FilterRemind_On"]}
def test_filter_remind_write_requires_existing_options(): 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 assert desc.write_fn("On", {}) is None
def test_remind_beep_gated_absent_when_no_option(): 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"]} 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 assert desc.exists_fn(rep, {}) is False
def test_remind_beep_gated_present_when_option_reported(): 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"]} rep = {"x.com.samsung.da.options": ["RemindBeep_On"]}
assert desc.exists_fn is not None
assert desc.exists_fn(rep, {}) is True 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(): 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"]} 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 path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["RemindBeep_Off"]} assert body == {"x.com.samsung.da.options": ["RemindBeep_Off"]}
def test_remind_beep_write_requires_existing_options(): 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 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.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import oven from custom_components.localthings.registry.capabilities import oven
from custom_components.localthings.registry.discovery import discover 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) # 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(): def test_oven_setpoint_write_is_read_modify_write():
cap = oven.OVEN_SETPOINT desc = _oven_setpoint_desc()
desc = cap.entities[0]
rep = { rep = {
"x.com.samsung.da.items": [ "x.com.samsung.da.items": [
{"x.com.samsung.da.id": "Target", "x.com.samsung.da.temperature": 0} {"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 assert path[-1] == "0" # writes back to the resource
# the produced body preserves the items-array shape the oven expects # 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) 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(): def test_oven_setpoint_rmw_preserves_other_item_fields():
"""RMW must not drop sibling fields (e.g. x.com.samsung.da.current).""" """RMW must not drop sibling fields (e.g. x.com.samsung.da.current)."""
desc = oven.OVEN_SETPOINT.entities[0] desc = _oven_setpoint_desc()
rep = { rep = {
"x.com.samsung.da.items": [ "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] item = body["x.com.samsung.da.items"][0]
assert item["x.com.samsung.da.desired"] == "200" assert item["x.com.samsung.da.desired"] == "200"
# sibling field preserved # sibling field preserved
@@ -67,22 +81,27 @@ def test_oven_setpoint_rmw_preserves_other_item_fields():
def test_oven_setpoint_clamps_to_step(): def test_oven_setpoint_clamps_to_step():
"""Setpoint must be a multiple of SETPOINT_STEP_C.""" """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"}]} 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 # 202 → nearest 5 = 200
assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "200" assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "200"
def test_oven_setpoint_rejects_out_of_range(): 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"}]} 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(10, rep) is None # below min (30)
assert desc.write_fn(300, rep) is None # above max (270) assert desc.write_fn(300, rep) is None # above max (270)
def test_oven_setpoint_rejects_missing_items(): 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 assert desc.write_fn(200, {}) is None
@@ -105,24 +124,31 @@ def _fahrenheit_rep(desired="0"):
def test_oven_setpoint_write_uses_fahrenheit_bounds(): def test_oven_setpoint_write_uses_fahrenheit_bounds():
desc = oven.OVEN_SETPOINT.entities[0] desc = _oven_setpoint_desc()
rep = _fahrenheit_rep() rep = _fahrenheit_rep()
# 350 is within F bounds (175-550) but above the C max (270) -- # 350 is within F bounds (175-550) but above the C max (270) --
# confirms the write path isn't silently still clamping to Celsius. # 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" assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "350"
def test_oven_setpoint_rejects_out_of_range_fahrenheit(): def test_oven_setpoint_rejects_out_of_range_fahrenheit():
desc = oven.OVEN_SETPOINT.entities[0] desc = _oven_setpoint_desc()
rep = _fahrenheit_rep() 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(100, rep) is None # below F min (175)
assert desc.write_fn(600, rep) is None # above F max (550) assert desc.write_fn(600, rep) is None # above F max (550)
def test_oven_setpoint_native_bounds_track_live_unit(): 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"}]} 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_min_fn(celsius_rep) == 30.0
assert desc.native_max_fn(celsius_rep) == 270.0 assert desc.native_max_fn(celsius_rep) == 270.0
fahrenheit_rep = _fahrenheit_rep() 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(): def test_oven_mode_options_nonempty():
desc = oven.OVEN_MODE.entities[0] desc = _oven_mode_desc()
assert callable(desc.options) assert callable(desc.options)
assert len(desc.options({})) > 0 assert len(desc.options({})) > 0
def test_oven_mode_options_falls_back_when_no_live_supported_modes(): 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) assert desc.options({}) == list(oven._OVEN_MODES)
def test_oven_mode_options_reads_live_supported_modes(): def test_oven_mode_options_reads_live_supported_modes():
"""issue #138: the device's own supportedModes list is used verbatim """issue #138: the device's own supportedModes list is used verbatim
when present, instead of the static _OVEN_MODES guess.""" when present, instead of the static _OVEN_MODES guess."""
desc = oven.OVEN_MODE.entities[0] desc = _oven_mode_desc()
resources = { resources = {
"/mode/vs/0": { "/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["Bake", "AirFryer", "SelfClean"], "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(): 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' 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 path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == [valid_mode] assert body["x.com.samsung.da.modes"] == [valid_mode]
def test_oven_mode_rejects_unknown(): 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 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. 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 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.""" 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"]} 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 path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == ["AirFryer"] assert body["x.com.samsung.da.modes"] == ["AirFryer"]
assert desc.write_fn("FrozenPizzaPlus", rep) is None assert desc.write_fn("FrozenPizzaPlus", rep) is None
@@ -205,20 +242,29 @@ def _mode_rep(*extra_opts):
def test_lamp_write_is_single_token(): def test_lamp_write_is_single_token():
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))
path, body = desc.write_fn("On", _mode_rep()) 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 path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["UpperLamp_On"]} assert body == {"x.com.samsung.da.options": ["UpperLamp_On"]}
def test_lamp_write_requires_existing_options(): 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 assert desc.write_fn("On", {}) is None
def test_sound_write_is_single_token(): def test_sound_write_is_single_token():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "sound") desc = next(
_path, body = desc.write_fn("Off", _mode_rep()) 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"]} 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 """NaturalSteam's slot may be absent from the live rep until first
write -- the single-token write covers both the insert and replace case write -- the single-token write covers both the insert and replace case
identically, since the device merges by prefix either way.""" identically, since the device merges by prefix either way."""
desc = next(e for e in oven.OVEN_MODE.entities if e.key == "natural_steam") desc = next(
_path, body = desc.write_fn("On", _mode_rep()) # no NaturalSteam_* in rep 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"]} 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(): def test_cook_time_write_produces_hms():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cook_time") desc = next(
path, body = desc.write_fn(90, {}) 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 path == ["operational", "state", "vs", "0"]
assert body["x.com.samsung.da.operationTime"] == "01:30:00" assert body["x.com.samsung.da.operationTime"] == "01:30:00"
assert body["x.com.samsung.da.remainingTime"] == "01:30:00" assert body["x.com.samsung.da.remainingTime"] == "01:30:00"
def test_cook_time_rejects_out_of_range(): 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(-1, {}) is None
assert desc.write_fn(1440, {}) 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.""" """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.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import microwave, range_hood from custom_components.localthings.registry.by_type import microwave, range_hood
from custom_components.localthings.registry.discovery import discover 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)) fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan( return LocalThingsRangeHoodFan(
coordinator or _FakeCoordinator(resources), cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
fan_bound, 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 BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own
/mode/vs/0 supportedModes rather than needing them hardcoded.""" /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.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_resources 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.capabilities import oven
from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device 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.""" those modes added to any Python list."""
_, resources = _range() _, resources = _range()
live_rep = resources["/mode/vs/0"] 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"] assert desc.options(resources) == live_rep["x.com.samsung.da.supportedModes"]
for mode in ( for mode in (
"ConvectionRoast", "ConvectionRoast",
@@ -75,6 +80,8 @@ def test_oven_mode_accepts_this_devices_supported_modes():
"SelfClean", "SelfClean",
"SteamClean", "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 path == ["mode", "vs", "0"]
assert body["x.com.samsung.da.modes"] == [mode] 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 collections import Counter
from typing import cast
import pytest import pytest
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.entity import _is_included from custom_components.localthings.entity import _is_included
from custom_components.localthings.registry.adapter import _key from custom_components.localthings.registry.adapter import _key
from custom_components.localthings.registry.entities import PLATFORM_OF 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, oic_res,
seeds, seeds,
) )
coordinator = _FakeCoordinator(full_resources, materialized) coordinator = cast(LocalThingsCoordinator, _FakeCoordinator(full_resources, materialized))
included = [b for b in bound if _is_included(b, coordinator)] included = [b for b in bound if _is_included(b, coordinator)]
keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included] 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} 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.""" """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 ( from homeassistant.components.water_heater import (
STATE_ECO, STATE_ECO,
@@ -11,6 +11,7 @@ from homeassistant.components.water_heater import (
) )
from homeassistant.const import STATE_OFF, UnitOfTemperature 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.by_type import ehs
from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import WaterHeaterDesc from custom_components.localthings.registry.entities import WaterHeaterDesc
@@ -47,7 +48,9 @@ def _entity(resources, coordinator=None):
ehs.REGISTRY.pattern_capabilities, ehs.REGISTRY.pattern_capabilities,
) )
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc)) 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(): def test_current_operation_reads_eco_when_on():