Fix range hood fan power targeting and kimchi mode write validation

- _speed_zero_is_off now keys off the hood resource's own
  settableMinFanSpeed/supportedFanSpeed fields instead of asking whether
  the device has any power resource at all, so a combi appliance's cavity
  /power/0 can no longer be toggled off by turning off just the vent fan.
- async_turn_on() no longer resets an already-running fan to its lowest
  speed when called without a percentage.
- _has_separate_power() reads through the O(1) resource cache instead of
  copying the full resource snapshot on every property access.
- _kimchi_mode_write rejects values the compartment didn't advertise in
  supportMode instead of writing them blind.
- kimchi_ripening_status no longer lowercases its value, since it has no
  enum catalog entry to translate the lowercased token back through.
- Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0
  aggregate fallback on the one fixture that reports both.

Adds regression tests for the combi-appliance power targeting, the
already-on turn_on no-op, kimchi mode write validation, and a kimchi
select display/write casing round-trip; a translation-coverage guard for
kimchi_zone_mode codes mirroring the existing AC preset one.
This commit is contained in:
Marc Billow committed 2026-07-28 02:10:01 +00:00
1 parent 265d94eded
commit cc3ce12aa4
7 files changed
+212 -32

No files matched your search

+65 -5
View File
@@ -241,25 +241,85 @@ class TestKimchiZone:
def test_write_derives_path_from_href(self):
desc = fridge.KIMCHI_ZONE.entities[0]
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
path, body = desc.write_fn(
'KIMCHI_STORAGE_COLD', {}, href='/status/kimchi/middle/vs/0')
'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0')
assert path == ['status', 'kimchi', 'middle', 'vs', '0']
assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'}
def test_write_without_href_is_rejected(self):
desc = fridge.KIMCHI_ZONE.entities[0]
assert desc.write_fn('KIMCHI_STORAGE_COLD', {}) is None
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None
def test_ripening_status_is_lowercased(self):
def test_write_rejects_value_outside_supportmode(self):
"""A value the compartment never advertised is rejected rather than
written blind -- this write path is unconfirmed against real
hardware (module docstring above KIMCHI_ZONE), so a bad value here
is a food-safety-adjacent outcome, not just a cosmetic one."""
desc = fridge.KIMCHI_ZONE.entities[0]
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
assert desc.write_fn(
'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0',
) is None
def test_ripening_status_passes_through_device_value(self):
"""No device_class='enum' catalog entry exists for this sensor, so
lowercasing it would only make the raw device token un-translatable
by HA -- pass the device's own casing straight through instead."""
desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status')
assert desc.value_fn('Off') == 'off'
assert desc.value_fn(None) is None
assert desc.value_fn('Off') == 'Off'
def test_door_reuses_open_state_helper(self):
desc = fridge.KIMCHI_DOOR_GENERIC.entities[0]
assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
async def test_zone_mode_select_round_trips_through_display_casing(self):
"""kimchi_zone_mode's displayed value (lowercase, catalog-translated)
and the raw device code it writes back can silently drift apart --
this is the one place that casing conversion could break. Runs
through the real discovery/select pipeline against the tp2x_ref_20k
kimchi fixture rather than a hand-built descriptor, so it also
catches use_instance_name key derivation going wrong."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.select import LocalThingsSelect
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
bound = discover(
resources, refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
)
mode_bound = next(
b for b in bound
if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0'
)
class _FakeCoordinator:
device_serial = 'TEST-SERIAL'
def __init__(self, resources, data):
self.last_resources = resources
self.data = data
self.commands = []
async def async_send_command(self, bound, value):
self.commands.append(value)
coordinator = _FakeCoordinator(resources, flatten(bound, resources))
entity = LocalThingsSelect(coordinator, mode_bound)
assert entity.current_option == 'kimchi_storage_normal'
assert 'kimchi_storage_cold' in entity.options
await entity.async_select_option('kimchi_storage_cold')
assert coordinator.commands == ['KIMCHI_STORAGE_COLD']
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered