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