Files
localthings/tests/test_air_purifier_capabilities.py
T
Marc Billow 2be742489b Fix airflow fan power-href preference and unique_id collision risk
Opus review of the previous commit caught two real bugs. The airflow fan's
power writes preferred /power/vs/0, copied from the TP1X fan class -- but
that order is only harmless there because TP1X never reports /power/0 at
all. This family's dumps carry both hrefs, and the power_switch entity is
unconditionally bound to /power/0 when present, so the fan was writing to
a different resource than power_switch reads/writes, leaving the two
entities disagreeing until the next poll. Flipped to prefer /power/0,
matching the range hood's fan and common.POWER_GENERIC.

Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity
unique_ids are derived from key alone, not href, so it collided with
air_purifier.FAN's own 'fan' key on the (currently unobserved, but
unenforced) possibility of a board reporting both.

Added a platform-level test file covering the power-href preference and
percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness.
2026-07-27 21:48:23 +00:00

141 lines
5.6 KiB
Python

"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
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.capabilities import air_purifier
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _purifier():
resources = _load_device('air_purifier')
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 = _purifier()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_model_resolves_to_air_purifier_registry():
reg, _ = _purifier()
assert reg is not None
assert reg.name == 'air_purifier'
def test_no_unbound_hrefs():
"""Every resource in the issue #56 dump binds or is ignored -- clears the
coverage-gap repair."""
reg, resources = _purifier()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active',
'diagnosis_status', 'airflow_fan', 'fan_direction',
'display_light', 'operating_mode',
):
assert key in state, key
def test_air_quality_sensor_values():
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
state = _state()
assert state['dust'] == 11
assert state['fine_dust'] == 9
assert state['super_fine_dust'] == 5
assert state['odor'] == 0
assert state['clean_level'] == 0
def test_filter_progress_reads_named_consumable_item():
"""FilterProgress is confirmed (issue #56) to count up as the filter
wears -- 100 means fully used and needs replacing, not "brand new"."""
assert _state()['filter_progress'] == 100
def test_diagnosis_reuses_dishwasher_capability():
"""/diagnosis/vs/0 has the identical field/write contract as
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
from custom_components.localthings.registry.capabilities import dishwasher
reg, _ = _purifier()
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
def test_light_switch_write_contract():
"""The display-light switch writes only the changed 'Light_*' token
(via laundry.option_write) -- confirmed on real hardware (issue #54)
that the device merges by prefix itself, so no read-modify-write of the
whole packed /mode/vs/0 options list is needed."""
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
rep = {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
]}
assert desc.rep_fn(rep) is True
assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': ['Light_Off']},
)
def test_operating_mode_is_a_read_only_diagnostic():
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
control -- issue #56's five running-state dumps confirmed it reads 'Off'
regardless of the device's actual fan setting, ruling out the original
guess that it was the fan-speed selector; its real purpose is still
unconfirmed (see the air_purifier.py module docstring)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
rep = {'x.com.samsung.da.options': ['Comode_Off']}
assert operating_mode.rep_fn(rep) == 'Off'
assert not hasattr(operating_mode, 'write_fn')
def test_blooming_not_modeled():
"""Confirmed (issue #56) to have no corresponding SmartThings app
setting -- dropped entirely rather than kept as an unexplained
diagnostic."""
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
def test_airflow_vs_fallback_only_binds_without_generic():
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
) is False
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
def test_airflow_fan_write_contract():
"""Confirmed via issue #56's second, properly-spaced diagnostics round
(two independent units, 60-90s apart per setting): /airflow/0's `speed`
is a clean, monotonic 0-4 code, so the write is a plain int passthrough
-- no named-preset table needed (see fan.py's LocalThingsAirflowFan)."""
fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'airflow_fan')
assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3})
assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == (
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
)
assert fan_desc.write_fn(('power', False, '/power/0'), {}) == (
['power', '0'], {'value': False},
)
def test_airflow_speed_reads_from_dump():
"""The fixture's /airflow/0 (power-off snapshot) reads speed=0, and
fan_direction stays a plain diagnostic (every dump seen reads 'Off'
regardless of fan setting)."""
state = _state()
assert state['airflow_fan'] == 0
assert state['fan_direction'] == 'Off'