diff --git a/tests/test_air_dresser_tp1_21_capabilities.py b/tests/test_air_dresser_tp1_21_capabilities.py index d6fe379..5258b58 100644 --- a/tests/test_air_dresser_tp1_21_capabilities.py +++ b/tests/test_air_dresser_tp1_21_capabilities.py @@ -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"} diff --git a/tests/test_air_dresser_tp2_20_capabilities.py b/tests/test_air_dresser_tp2_20_capabilities.py index 3fc9406..3ff9546 100644 --- a/tests/test_air_dresser_tp2_20_capabilities.py +++ b/tests/test_air_dresser_tp2_20_capabilities.py @@ -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 diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 5862b68..6081e78 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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, ) diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 64dfef9..fe3c08f 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -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"}, diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py index 414ccfd..575ee38 100644 --- a/tests/test_airconditioner_tp1x_rac_01001_fan.py +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -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(): diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 2bf6931..716af72 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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: diff --git a/tests/test_capability.py b/tests/test_capability.py index 1cd9ac3..bef599e 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -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") diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 2a2a2c7..4b6609f 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -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 diff --git a/tests/test_coordinator_send_command.py b/tests/test_coordinator_send_command.py index dc26cca..2b9b04a 100644 --- a/tests/test_coordinator_send_command.py +++ b/tests/test_coordinator_send_command.py @@ -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: diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 229ff87..e82fd23 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -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" diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index 1153091..74b95ed 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -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(): diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 830fbaa..3d755ae 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -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 diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 91b2c9c..8b4e759 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -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 diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 800977e..f0981ee 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -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 diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 9043589..4eafe92 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -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, ) diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py index 436bf4e..fd4a4c4 100644 --- a/tests/test_range_ne63a6511_capabilities.py +++ b/tests/test_range_ne63a6511_capabilities.py @@ -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] diff --git a/tests/test_unique_ids.py b/tests/test_unique_ids.py index b4f9d6f..f679508 100644 --- a/tests/test_unique_ids.py +++ b/tests/test_unique_ids.py @@ -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} diff --git a/tests/test_water_heater_ehs.py b/tests/test_water_heater_ehs.py index d3a6b3c..d3dc935 100644 --- a/tests/test_water_heater_ehs.py +++ b/tests/test_water_heater_ehs.py @@ -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():