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

106 lines
3.6 KiB
Python

"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56)."""
from custom_components.localthings.fan import LocalThingsAirflowFan
from custom_components.localthings.registry.by_type import air_purifier
from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW
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-AIRFLOW-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 _entity(resources, coordinator=None):
bound = discover(
resources,
air_purifier.REGISTRY.capabilities,
air_purifier.REGISTRY.pattern_capabilities,
)
fan_bound = next(
item for item in bound
if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
)
return LocalThingsAirflowFan(
coordinator or _FakeCoordinator(resources), fan_bound,
)
def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
entity = _entity(_load_device('air_purifier'))
assert entity.is_on is False
assert entity.speed_count == 5
assert entity.percentage == 0
def test_active_codes_map_to_ordered_percentages():
"""Confirmed via issue #56's second, properly-spaced diagnostics round:
/airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High."""
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)):
resources['/airflow/0']['speed'] = code
assert _entity(resources).percentage == expected_percentage
async def test_power_write_prefers_standard_resource_when_both_exist():
"""Both /power/0 and /power/vs/0 are present on this family's dumps --
/power/0 must win, matching common.POWER_GENERIC's own preference (the
power_switch entity is unconditionally bound to /power/0 whenever it's
present, so writing here to /power/vs/0 first would leave the two
entities disagreeing until the next poll)."""
resources = _load_device('air_purifier')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
async def test_power_write_falls_back_to_vendor_resource():
resources = _load_device('air_purifier')
resources.pop('/power/0')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
async def test_set_percentage_writes_raw_speed_code():
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(60)
assert coordinator.commands[-1][1] == ('speed', 2)
async def test_set_percentage_zero_turns_off():
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(0)
assert coordinator.commands[-1][1] == ('power', False, '/power/0')