Files
localthings/tests/test_air_purifier_vtww_fan.py
Marc Billow 5a73a25005 Address independent code review findings on PR #167
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.
2026-07-28 14:37:32 +00:00

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'