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

119 lines
5.0 KiB
Python

"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93).
Module-level dicts/constants with no coordinator/entity dependency, so --
like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're
testable directly.
"""
from homeassistant.components.climate import HVACMode
from custom_components.localthings.climate import (
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE,
PRESET_AI_COMFORT, _preset_to_ha,
)
def test_auto_maps_to_hvac_auto():
"""The device's 'Auto' is a single-setpoint "device decides" mode -> HA
HVACMode.AUTO, not HEAT_COOL (issue #91 review): HEAT_COOL implies a
two-setpoint heat+cool range these single-setpoint units (including
cool-only models) don't have. AIComfort is handled separately, not
folded into this map."""
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.AUTO
def test_aicomfort_not_in_flat_hvac_map():
"""AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on
top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of
a distinct HVACMode value (see the climate.py module comment)."""
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
def test_hvac_auto_writes_back_to_plain_auto_not_aicomfort():
"""HVACMode.AUTO is reachable via async_set_hvac_mode -- it writes the
device's plain 'Auto' code. AIComfort stays reachable only through the
ai_comfort preset, since it isn't a flat _DEVICE_TO_HVAC entry (see
test_aicomfort_not_in_flat_hvac_map) and so can never win the reverse
{v: k} dict even though both map to HVACMode.AUTO conceptually."""
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'Auto'
def test_fan_only_still_reachable_via_wind():
"""Guard against regressing the existing 'Wind' -> FAN_ONLY entry while
editing this map."""
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
def test_fan_only_still_reachable_via_fan():
"""'Fan' (e.g. TP1X_DA-AC-RAC-01011) is a second FAN_ONLY spelling
alongside 'Wind' -- issue #91."""
assert _DEVICE_TO_HVAC['Fan'] == HVACMode.FAN_ONLY
def test_fan_only_reverse_fallback_prefers_wind():
"""_device_code_for_hvac() resolves FAN_ONLY from a unit's own
supportedModes first, so _HVAC_TO_DEVICE is only a fallback for a unit
reporting no supportedModes at all. That fallback must stay 'Wind' (the
original single spelling, predating 'Fan') rather than silently
flipping to whichever of the two duplicate-value entries happens to
come last in _DEVICE_TO_HVAC."""
assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == 'Wind'
def test_preset_ai_comfort_constant():
assert PRESET_AI_COMFORT == 'ai_comfort'
def test_preset_to_ha_off_maps_to_preset_none():
from homeassistant.components.climate import PRESET_NONE
assert _preset_to_ha('Off') == PRESET_NONE
def test_preset_to_ha_lowercases_other_codes():
"""Every other device code is exposed as its lowercased self -- resolved
dynamically, not via a per-model table (issue #91)."""
assert _preset_to_ha('Sleep') == 'sleep'
assert _preset_to_ha('NanoSleep') == 'nanosleep'
assert _preset_to_ha('MotionIndirect') == 'motionindirect'
def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
"""TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes
(0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already
match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName
fallback needed for this particular board, unlike issue #155's
TP1X_DA-AC-RAC-01001_0000.
Asserts the live climate entity's actual fan_modes/fan_mode output
(not just that the module constant _DEVICE_TO_FAN happens to have
these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass
even if fan_modes/fan_mode were completely broken, since it never
touches the entity at all.
"""
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-FAC-BORA-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
def resource(self, href):
return self.last_resources.get(href, {})
resources = _load_device('airconditioner_fac_bora')
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound)
assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo']
assert entity.fan_mode == 'auto'