Files
localthings/tests/test_air_dresser_capabilities.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

71 lines
2.6 KiB
Python

"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162)."""
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.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
return reg, resources
def _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_course_select_present_and_reads_current_selection():
"""This was the actual reported gap: /course/vs/0 was previously
entirely unbound, so no cycle/mode select existed at all."""
state = _state()
assert state['cycle'] == '01'
def test_course_options_derived_from_supported_options_fallback():
"""No /wm/editcourse/vs/0 on this board at all, so the option list must
come from laundry.cycle_options' supportedOptions decode rather than
editCourseList -- confirmed distinct codes, current selection included."""
from custom_components.localthings.registry.capabilities.laundry import cycle_options
_, resources = _air_dresser()
codes = cycle_options(resources)
assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08']
def test_wrinkle_prevent_present_dry_level_fields_absent():
"""AIR_DRESSER_SETTINGS only binds wrinkle_prevent -- dryLevel/dryTime/
dryerType are dryer-only fields this device never reports, so they
should not appear as always-empty entities."""
state = _state()
assert state['wrinkle_prevent'] is False
for key in ('dry_level', 'dry_time', 'dryer_type'):
assert key not in state, key
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'child_lock', 'remote_control', 'cycle',
'machine_state', 'progress', 'progress_percentage', 'finish_time',
'completion_minutes', 'delay_start_hours', 'diagnosis_status',
'job_beginning_status', 'wrinkle_prevent', 'energy_kwh',
):
assert key in state, key