Files
localthings/tests/test_microwave_capabilities.py
T
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

220 lines
9.4 KiB
Python

"""Unit tests for the microwave-family capabilities (issue #121/#66 split
into their own device type instead of being folded into oven.py)."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage
# ---------------------------------------------------------------------------
def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
from tests.conftest import _load_device
resources = _load_device('microwave_mw7300b')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None
assert reg.name == 'microwave'
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
"""Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in
vent fan) was previously unbound on this family."""
from tests.conftest import _load_device
resources = _load_device('microwave_me7500d')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None
assert reg.name == 'microwave'
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
# ---------------------------------------------------------------------------
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------
def test_microwave_setpoint_write_is_read_modify_write():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
path, body = desc.write_fn(180, rep)
assert path == ['temperatures', 'vs', '0']
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
def test_microwave_setpoint_rmw_preserves_other_item_fields():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{
'x.com.samsung.da.current': '150',
'x.com.samsung.da.desired': '150',
}]}
path, body = desc.write_fn(180, rep)
item = body['x.com.samsung.da.items'][0]
assert item['x.com.samsung.da.desired'] == '180'
assert item['x.com.samsung.da.current'] == '150'
def test_microwave_setpoint_clamps_to_step():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
_, body = desc.write_fn(182, rep) # nearest 5 = 180
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
def test_microwave_setpoint_rejects_out_of_range():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]}
assert desc.write_fn(20, rep) is None # below min (40)
assert desc.write_fn(210, rep) is None # above max (200)
def test_microwave_setpoint_rejects_missing_items():
desc = microwave.MICROWAVE_SETPOINT.entities[0]
assert desc.write_fn(180, {}) is None
def test_microwave_setpoint_exists_only_for_celsius():
"""No Fahrenheit dump exists for this family (unlike oven.py's, verified
against issue #44) -- the writable setpoint stays hidden rather than
showing unverified bounds under the wrong unit."""
desc = microwave.MICROWAVE_SETPOINT.entities[0]
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'}]}
assert desc.exists_fn(celsius_rep, {}) is True
assert desc.exists_fn(fahrenheit_rep, {}) is False
# ---------------------------------------------------------------------------
# MICROWAVE_CAVITY — power_level sensor
# ---------------------------------------------------------------------------
def test_power_level_parses_watt_suffix():
"""Issue #121's combi dump reports e.g. '0W'."""
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('900W') == 900
def test_power_level_parses_bare_number():
"""Issue #137's plain microwave reports the bare number, no 'W' suffix."""
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('0') == 0
def test_power_level_handles_missing_value():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn(None) is None
# ---------------------------------------------------------------------------
# MICROWAVE_MODE — SelectDesc with non-empty, family-specific options
# ---------------------------------------------------------------------------
def test_microwave_mode_options_nonempty():
desc = microwave.MICROWAVE_MODE.entities[0]
assert callable(desc.options)
options = desc.options({})
assert len(options) > 0
assert 'MicroWave' in options
assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry'
def test_microwave_mode_options_reads_live_supported_modes():
"""issue #152's ME7500D reports only 4 of the 11 union-of-all-dumps
_MICROWAVE_MODES -- the live supportedModes list is used verbatim when
present, same live-first pattern as oven._oven_mode_options, instead of
offering users modes their own unit doesn't have."""
desc = microwave.MICROWAVE_MODE.entities[0]
resources = {'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'],
}}
assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']
def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0]
path, body = desc.write_fn('MicroWave', {})
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['MicroWave']
def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', {}) is None
def test_microwave_mode_write_validates_against_live_supported_modes():
"""A device reporting its own supportedModes is validated against that
list, not the static union-of-all-dumps fallback -- 'AirFryer' is a
valid _MICROWAVE_MODES entry but must still be rejected for a unit
whose own supportedModes doesn't include it."""
desc = microwave.MICROWAVE_MODE.entities[0]
rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']}
path, body = desc.write_fn('MicroWave', rep)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['MicroWave']
assert desc.write_fn('AirFryer', rep) is None
# ---------------------------------------------------------------------------
# MICROWAVE_MODE — lamp/sound options-array writes
# ---------------------------------------------------------------------------
def test_sound_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
rep = {'x.com.samsung.da.options': ['Sound_On']}
path, body = desc.write_fn('Off', rep)
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['Sound_Off']}
def test_lamp_gated_absent_when_no_lamp_option():
"""Issue #121's combi dump has no 'Lamp_*' token at all -- unlike
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')
rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']}
assert desc.exists_fn(rep, {}) is False
def test_lamp_gated_present_when_lamp_option_reported():
"""Issue #137's plain microwave reports 'Lamp_Off'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']}
assert desc.exists_fn(rep, {}) is True
def test_lamp_write_is_single_token():
"""issue #152: the device has never been observed accepting 'On' --
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')
rep = {'x.com.samsung.da.options': ['Lamp_Off']}
path, body = desc.write_fn('On', rep)
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['Lamp_High']}
def test_lamp_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.write_fn('On', {}) is None
def test_lamp_reads_off_as_false():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.value_fn(['Lamp_Off']) is False
def test_lamp_reads_any_non_off_level_as_true():
"""issue #152's ME7500D reports 'Lamp_High', not the binary 'Lamp_On'
#137's dump implied -- any non-Off/non-absent value must read as on, not
just a literal 'On'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.value_fn(['Lamp_High']) is True