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:
@@ -13,6 +13,7 @@ raw code until a reporter can map codes to names in the SmartThings app.
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc, SwitchDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
@@ -53,9 +54,12 @@ def test_buzzer_sound_present_and_writable():
|
||||
desc = next(
|
||||
e
|
||||
for e in air_dresser.REGISTRY.capabilities["/buzzersound/vs/0"][0].entities
|
||||
if e.key == "buzzer_sound"
|
||||
if e.key == "buzzer_sound" and isinstance(e, SelectDesc)
|
||||
)
|
||||
path, body = desc.write_fn("Off", {})
|
||||
assert desc.write_fn is not None
|
||||
result = desc.write_fn("Off", {})
|
||||
assert result is not None
|
||||
path, body = result
|
||||
assert path == ["buzzersound", "vs", "0"]
|
||||
assert body == {"setBuzzerSound": "Off"}
|
||||
|
||||
@@ -75,8 +79,11 @@ def test_sanitize_present_and_toggles():
|
||||
desc = next(
|
||||
e
|
||||
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
|
||||
if e.key == "sanitize"
|
||||
if e.key == "sanitize" and isinstance(e, SwitchDesc)
|
||||
)
|
||||
path, body = desc.write_fn("On", {})
|
||||
assert desc.write_fn is not None
|
||||
result = desc.write_fn("On", {})
|
||||
assert result is not None
|
||||
path, body = result
|
||||
assert path == ["airdresseroption", "sanitize", "vs", "0"]
|
||||
assert body == {"x.com.samsung.da.sanitize": "On"}
|
||||
|
||||
@@ -11,6 +11,7 @@ capability #162's dump doesn't report at all.
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SwitchDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
@@ -77,9 +78,12 @@ def test_sanitize_present_and_toggles():
|
||||
desc = next(
|
||||
e
|
||||
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
|
||||
if e.key == "sanitize"
|
||||
if e.key == "sanitize" and isinstance(e, SwitchDesc)
|
||||
)
|
||||
path, body = desc.write_fn("On", {})
|
||||
assert desc.write_fn is not None
|
||||
result = desc.write_fn("On", {})
|
||||
assert result is not None
|
||||
path, body = result
|
||||
assert path == ["airdresseroption", "sanitize", "vs", "0"]
|
||||
assert body == {"x.com.samsung.da.sanitize": "On"}
|
||||
assert desc.write_fn("Sparkle", {}) is None
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
|
||||
from custom_components.localthings.registry.entities import (
|
||||
ClimateDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
@@ -103,7 +108,9 @@ def test_climate_write_targets():
|
||||
/power/0 is absent on most boards and a non-authoritative mirror where
|
||||
present; /temperature/desired/0 is only written via the temperature_ocf
|
||||
kind, on boards that have the OCF pair)."""
|
||||
write = airconditioner.CLIMATE.entities[0].write_fn
|
||||
climate_desc = next(e for e in airconditioner.CLIMATE.entities if isinstance(e, ClimateDesc))
|
||||
assert climate_desc.write_fn is not None
|
||||
write = climate_desc.write_fn
|
||||
assert write(("power", True), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "On"})
|
||||
assert write(("power", False), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "Off"})
|
||||
assert write(("mode", "Heat"), {}) == (
|
||||
@@ -285,13 +292,17 @@ def test_caww_tp2_sac_installationinfo_is_ignored():
|
||||
|
||||
|
||||
def test_mute_once_write_target():
|
||||
write = airconditioner.MUTE_ONCE.entities[0].write_fn
|
||||
desc = next(e for e in airconditioner.MUTE_ONCE.entities if isinstance(e, SwitchDesc))
|
||||
assert desc.write_fn is not None
|
||||
write = desc.write_fn
|
||||
assert write("On", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "On"})
|
||||
assert write("Off", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "Off"})
|
||||
|
||||
|
||||
def test_display_light_write_target():
|
||||
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
|
||||
desc = next(e for e in airconditioner.DISPLAY_LIGHT.entities if isinstance(e, SwitchDesc))
|
||||
assert desc.write_fn is not None
|
||||
write = desc.write_fn
|
||||
assert write("On", {}) == (["light", "vs", "0"], {"mode": "On"})
|
||||
assert write("Off", {}) == (["light", "vs", "0"], {"mode": "Off"})
|
||||
|
||||
@@ -375,7 +386,12 @@ def test_display_light_option_parsing_and_gating():
|
||||
def test_mode_options_display_light_write_is_inverted_single_token():
|
||||
"""Turning the lamp ON writes the inverted 'Light_Off' token as a
|
||||
single-element options list (single-token merge); OFF writes 'Light_On'."""
|
||||
sw = next(e for e in airconditioner.CLIMATE.entities if e.key == "display_light")
|
||||
sw = next(
|
||||
e
|
||||
for e in airconditioner.CLIMATE.entities
|
||||
if e.key == "display_light" and isinstance(e, SwitchDesc)
|
||||
)
|
||||
assert sw.write_fn is not None
|
||||
assert sw.write_fn("On", {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Light_Off"]},
|
||||
@@ -433,12 +449,14 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent():
|
||||
CURRENT_TEMPERATURE when a device (like this one) reports both
|
||||
/temperature/current/0 and /temperatures/vs/0."""
|
||||
match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn
|
||||
assert match is not None
|
||||
assert match({}, {"/temperature/current/0": {}}) is False
|
||||
assert match({}, {}) is True
|
||||
|
||||
|
||||
def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
|
||||
desc = airconditioner.HUMIDITY.entities[0]
|
||||
assert desc.rep_fn is not None
|
||||
rep = {"x.com.samsung.da.humidity": "0", "x.com.samsung.da.fivepercentHumidity": "42"}
|
||||
assert desc.rep_fn(rep) == 42.0
|
||||
|
||||
@@ -448,6 +466,7 @@ def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent():
|
||||
Their plain field is not stuck -- it carries a reading while Air monitoring
|
||||
is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
|
||||
desc = airconditioner.HUMIDITY.entities[0]
|
||||
assert desc.rep_fn is not None
|
||||
assert desc.rep_fn({"x.com.samsung.da.humidity": "51"}) == 51.0
|
||||
assert desc.rep_fn({"x.com.samsung.da.humidity": "0"}) is None
|
||||
assert desc.rep_fn({}) is None
|
||||
@@ -461,6 +480,7 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through():
|
||||
fallback field collapses 0 -- fivepercentHumidity's 0 is a real
|
||||
reading."""
|
||||
desc = airconditioner.HUMIDITY.entities[0]
|
||||
assert desc.rep_fn is not None
|
||||
assert desc.rep_fn({"x.com.samsung.da.fivepercentHumidity": "0"}) == 0.0
|
||||
|
||||
|
||||
@@ -580,21 +600,25 @@ def test_lnx_rac_heatpump_enable_switches_are_writable():
|
||||
|
||||
|
||||
def test_lnx_rac_heatpump_absence_power_saving_write_target():
|
||||
write = next(
|
||||
desc = next(
|
||||
e
|
||||
for e in airconditioner.ABSENCE_POWER_SAVING.entities
|
||||
if e.key == "absence_power_saving_active"
|
||||
).write_fn
|
||||
if e.key == "absence_power_saving_active" and isinstance(e, SwitchDesc)
|
||||
)
|
||||
assert desc.write_fn is not None
|
||||
write = desc.write_fn
|
||||
assert write("On", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "On"})
|
||||
assert write("Off", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "Off"})
|
||||
|
||||
|
||||
def test_lnx_rac_heatpump_motion_detect_wind_write_target():
|
||||
write = next(
|
||||
desc = next(
|
||||
e
|
||||
for e in airconditioner.MOTION_DETECT_WIND.entities
|
||||
if e.key == "motion_detect_wind_active"
|
||||
).write_fn
|
||||
if e.key == "motion_detect_wind_active" and isinstance(e, SwitchDesc)
|
||||
)
|
||||
assert desc.write_fn is not None
|
||||
write = desc.write_fn
|
||||
assert write("On", {}) == (
|
||||
["option", "motiondetectwind", "stateful", "vs", "0"],
|
||||
{"status": "On"},
|
||||
@@ -710,6 +734,7 @@ def test_tropical_night_absent_when_no_sleep_token():
|
||||
reg = for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert reg is not None
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert "tropical_night_mode" not in state
|
||||
|
||||
@@ -723,6 +748,7 @@ def test_tropical_night_state_levels_across_fixtures():
|
||||
r = for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert r is not None
|
||||
return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get(
|
||||
"tropical_night_mode"
|
||||
)
|
||||
@@ -736,13 +762,18 @@ def test_air_filter_usage_hours_reads_raw_count():
|
||||
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
|
||||
filter replacement -- total_increasing, not measurement. Unit comes from
|
||||
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
|
||||
desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_usage_hours")
|
||||
desc = next(
|
||||
e
|
||||
for e in airconditioner.AIR_FILTER.entities
|
||||
if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc)
|
||||
)
|
||||
assert desc.value_fn("41") == 41
|
||||
assert desc.value_fn(41) == 41
|
||||
assert desc.value_fn(None) is None
|
||||
assert desc.value_fn("not-a-number") is None
|
||||
assert desc.device_class == "duration"
|
||||
assert desc.state_class == "total_increasing"
|
||||
assert desc.unit_fn is not None
|
||||
assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Hour"}) == "h"
|
||||
assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Minute"}) == "min"
|
||||
assert desc.unit_fn({}) == "h" # static fallback when the field is absent
|
||||
@@ -758,6 +789,7 @@ def test_air_filter_threshold_is_writable_select():
|
||||
desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_threshold")
|
||||
assert isinstance(desc, SelectDesc)
|
||||
assert desc.options_field == "x.com.samsung.da.supportedFilterDesiredUsage"
|
||||
assert desc.exists_fn is not None
|
||||
assert (
|
||||
desc.exists_fn(
|
||||
{"x.com.samsung.da.supportedFilterDesiredUsage": ["180", "300", "500", "700"]}, {}
|
||||
@@ -770,6 +802,7 @@ def test_air_filter_threshold_is_writable_select():
|
||||
assert desc.value_fn(500) == "500"
|
||||
assert desc.value_fn(None) is None
|
||||
# Write POSTs the selected option as the scalar field.
|
||||
assert desc.write_fn is not None
|
||||
assert desc.write_fn("700", {}) == (
|
||||
["filter", "airdustfilter", "vs", "0"],
|
||||
{"x.com.samsung.da.filterDesiredUsage": "700"},
|
||||
|
||||
@@ -11,13 +11,14 @@ any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a
|
||||
second numeric scale.
|
||||
"""
|
||||
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from custom_components.localthings.climate import (
|
||||
_DEVICE_TO_FAN,
|
||||
LocalThingsClimate,
|
||||
_wind_strength_label,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry import by_type
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
@@ -53,9 +54,12 @@ def _climate(resources, coordinator=None):
|
||||
reg = by_type.for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert reg is not None
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
|
||||
return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound)
|
||||
return LocalThingsClimate(
|
||||
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), climate_bound
|
||||
)
|
||||
|
||||
|
||||
def test_wind_strength_label_reads_the_devices_own_modes_name():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user