Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET, PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree with `ruff format`, and fixes the pre-existing lint and type-check debt those tools surfaced so both run clean. Production-code type fixes include: HA's ConfigFlowResult vs. the generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via cast() instead of an unchecked annotation, converting HA device_class strings to their proper enum types, a resolve_registry callback typed as `object` instead of `DeviceRegistry | None`, and a couple of other narrow correctness fixes (CA key type validation, an index-out-of-bounds false positive from an empty-tuple fallback, a bool/dict argument swap). Test-file fixes are mechanical: narrowing SamsungEntityDescription to the correct subclass via isinstance()/cast() before accessing subclass-only fields, and asserting Optional write_fn/unit_fn fields are set before calling them.
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air
|
|
(issue #151).
|
|
|
|
This board's /wind/strength/vs/0 reports numeric wind-strength codes
|
|
("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/
|
|
"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR
|
|
family's /mode/vs/0 (issue #130) where supportedModes IS the name list
|
|
already. LocalThingsAirPurifierFan._label_for_code resolves both shapes
|
|
without a per-model map.
|
|
"""
|
|
|
|
from typing import ClassVar, cast
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.fan import LocalThingsAirPurifierFan
|
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
|
from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH
|
|
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-VTWW-SERIAL"
|
|
device_info: ClassVar[dict] = {}
|
|
data: ClassVar[dict] = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
self.commands = []
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
def canonical_resources(self, subdevice):
|
|
# Every bound entity in this test uses the default MAIN
|
|
# subdevice, so the canonical view is just the raw snapshot
|
|
# (issue #177 -- see LocalThingsEntity._resources).
|
|
return self.last_resources
|
|
|
|
async def async_send_command(self, bound, payload):
|
|
self.commands.append((bound, payload))
|
|
|
|
|
|
def _resources():
|
|
return _load_device("air_purifier_vtww")
|
|
|
|
|
|
def _reg(resources):
|
|
info = resources["/information/vs/0"]
|
|
return for_device_by_model(
|
|
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
|
)
|
|
|
|
|
|
def _entity(resources, coordinator=None):
|
|
reg = _reg(resources)
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
fan_bound = next(
|
|
item for item in bound if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH
|
|
)
|
|
return LocalThingsAirPurifierFan(
|
|
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), fan_bound
|
|
)
|
|
|
|
|
|
def test_resolves_to_air_purifier_registry():
|
|
assert _reg(_resources()).name == "air_purifier"
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
resources = _resources()
|
|
reg = _reg(resources)
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_preset_modes_come_from_modes_name_not_the_raw_codes():
|
|
entity = _entity(_resources())
|
|
assert entity.preset_modes == ["smart", "max", "windfree", "sleep"]
|
|
|
|
|
|
def test_preset_mode_reads_the_current_code_via_modes_name():
|
|
"""Fixture's current mode is '87' -> modesName[0] 'SMART'."""
|
|
entity = _entity(_resources())
|
|
assert entity.preset_mode == "smart"
|
|
|
|
|
|
async def test_set_preset_mode_writes_back_the_raw_code():
|
|
resources = _resources()
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_preset_mode("windfree")
|
|
|
|
assert coordinator.commands[-1][1] == ("mode", "90")
|
|
|
|
|
|
def test_is_on_reads_vendor_power():
|
|
entity = _entity(_resources())
|
|
assert entity.is_on is False
|
|
|
|
|
|
def test_wind_strength_fan_key_does_not_collide_with_mode_fan():
|
|
"""WIND_STRENGTH_FAN and FAN both live in this registry and both are
|
|
FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's
|
|
_key), not href, so a shared key would silently shadow one entity if a
|
|
board ever bound both (see AIRFLOW_GENERIC's own comment on this exact
|
|
hazard). No real dump reports both hrefs today, but the keys must stay
|
|
distinct regardless."""
|
|
from custom_components.localthings.registry.capabilities import air_purifier
|
|
|
|
fan_keys = {
|
|
entity.key
|
|
for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN)
|
|
for entity in cap.entities
|
|
}
|
|
assert fan_keys == {"fan", "wind_strength_fan"}
|
|
|
|
|
|
def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
|
|
"""Synthetic combination (no real dump reports both hrefs) proving the
|
|
two FanDescs don't shadow each other in flatten()'s key-based state dict
|
|
even if a future board did report both."""
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
|
|
resources = _resources()
|
|
resources["/mode/vs/0"] = {
|
|
"x.com.samsung.da.modes": ["Smart"],
|
|
"x.com.samsung.da.supportedModes": ["Smart", "Max", "Mid", "WindFree", "Sleep"],
|
|
}
|
|
reg = _reg(resources)
|
|
unbound = []
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
state = flatten(bound, resources)
|
|
assert state["fan"] == "Smart"
|
|
assert state["wind_strength_fan"] == "87"
|