Two real bugs, both latent (no shipped fixture exercised them), plus a
consistency gap and a couple of correctness/DRY nits flagged by review:
- async_set_fan_mode resolved a fan_mode label against the static
_FAN_TO_DEVICE reverse map before checking whether the resulting code is
actually one of the unit's own supportedModes. A board using non-standard
wind-strength codes while still spelling a standard-looking label in
modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently
write a code ("1"/"3"/"4") the device never advertised. Now validates the
static hit against the unit's own supported codes before trusting it,
falling through to the live modesName scan otherwise.
- air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in
the same registry -- BoundEntity's unique_id is built from key alone, not
href, so a board reporting both hrefs would have one fan entity silently
shadow the other. Renamed to 'wind_strength_fan' (translation_key
unchanged). No shipped fixture reports both hrefs today, but the two caps
living in the same registry made this a real latent hazard, the exact one
AIRFLOW_GENERIC's own comment already documents and deliberately avoids.
- microwave.py's cooking_mode select still used a static, union-of-all-
dumps mode list, even though both shipped microwave fixtures already
report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape
oven._oven_mode_options was just built to prefer over exactly this kind
of static list (issue #138's follow-up, this same PR's skill update).
ME7500D advertises 4 modes; the select was offering 11. Applied the same
live-first, static-fallback pattern.
- Added the issue #152 fixture the microwave lamp fix was missing (the
SKILL.md step this PR itself added asks for one).
- climate.py's _legacy_airflow rebuilt a 2-key presence dict from
coordinator.resource()'s truthiness, which collapses "href absent" and
"href present with an empty {} rep" to the same falsy value -- while
is_legacy_board (and discover()'s own binding) test key membership, not
truthiness. Simplified to pass last_resources through directly, matching
is_legacy_board's actual contract instead of a cheaper approximation of
it, so the "can never disagree" claim in both docstrings is actually true.
- Hoisted the 'power' payload branch duplicated verbatim across
_airflow_fan_write/_fan_write/_wind_strength_fan_write into one
_power_write helper (registry/capabilities/air_purifier.py).
- Removed two now-unused imports (test_air_dresser_capabilities.py,
test_air_purifier_vtww_fan.py) and replaced a tautological
code-in-_DEVICE_TO_FAN check with one that actually exercises the live
climate entity's fan_modes/fan_mode (test_climate_ac_modes.py).
- Fixed a pre-existing (not from this PR) no-op test on main --
test_registry_reproduces_golden_state_keys_for_induction_cooktop computed
golden/state_keys and never asserted on them.
756 tests pass.
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air
|
|
(issue #151).
|
|
|
|
This board's /wind/strength/vs/0 reports numeric wind-strength codes
|
|
("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/
|
|
"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR
|
|
family's /mode/vs/0 (issue #130) where supportedModes IS the name list
|
|
already. LocalThingsAirPurifierFan._label_for_code resolves both shapes
|
|
without a per-model map.
|
|
"""
|
|
from custom_components.localthings.fan import LocalThingsAirPurifierFan
|
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
|
from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import FanDesc
|
|
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_serial = 'TEST-VTWW-SERIAL'
|
|
device_info = {}
|
|
data = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
self.commands = []
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
async def async_send_command(self, bound, payload):
|
|
self.commands.append((bound, payload))
|
|
|
|
|
|
def _resources():
|
|
return _load_device('air_purifier_vtww')
|
|
|
|
|
|
def _reg(resources):
|
|
info = resources['/information/vs/0']
|
|
return for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
|
|
|
|
|
def _entity(resources, coordinator=None):
|
|
reg = _reg(resources)
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
fan_bound = next(
|
|
item for item in bound
|
|
if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH
|
|
)
|
|
return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound)
|
|
|
|
|
|
def test_resolves_to_air_purifier_registry():
|
|
assert _reg(_resources()).name == 'air_purifier'
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
resources = _resources()
|
|
reg = _reg(resources)
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_preset_modes_come_from_modes_name_not_the_raw_codes():
|
|
entity = _entity(_resources())
|
|
assert entity.preset_modes == ['smart', 'max', 'windfree', 'sleep']
|
|
|
|
|
|
def test_preset_mode_reads_the_current_code_via_modes_name():
|
|
"""Fixture's current mode is '87' -> modesName[0] 'SMART'."""
|
|
entity = _entity(_resources())
|
|
assert entity.preset_mode == 'smart'
|
|
|
|
|
|
async def test_set_preset_mode_writes_back_the_raw_code():
|
|
resources = _resources()
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_preset_mode('windfree')
|
|
|
|
assert coordinator.commands[-1][1] == ('mode', '90')
|
|
|
|
|
|
def test_is_on_reads_vendor_power():
|
|
entity = _entity(_resources())
|
|
assert entity.is_on is False
|
|
|
|
|
|
def test_wind_strength_fan_key_does_not_collide_with_mode_fan():
|
|
"""WIND_STRENGTH_FAN and FAN both live in this registry and both are
|
|
FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's
|
|
_key), not href, so a shared key would silently shadow one entity if a
|
|
board ever bound both (see AIRFLOW_GENERIC's own comment on this exact
|
|
hazard). No real dump reports both hrefs today, but the keys must stay
|
|
distinct regardless."""
|
|
from custom_components.localthings.registry.capabilities import air_purifier
|
|
|
|
fan_keys = {
|
|
entity.key
|
|
for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN)
|
|
for entity in cap.entities
|
|
}
|
|
assert fan_keys == {'fan', 'wind_strength_fan'}
|
|
|
|
|
|
def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
|
|
"""Synthetic combination (no real dump reports both hrefs) proving the
|
|
two FanDescs don't shadow each other in flatten()'s key-based state dict
|
|
even if a future board did report both."""
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
|
|
resources = _resources()
|
|
resources['/mode/vs/0'] = {
|
|
'x.com.samsung.da.modes': ['Smart'],
|
|
'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'],
|
|
}
|
|
reg = _reg(resources)
|
|
unbound = []
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
state = flatten(bound, resources)
|
|
assert state['fan'] == 'Smart'
|
|
assert state['wind_strength_fan'] == '87'
|