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.
170 lines
5.5 KiB
Python
170 lines
5.5 KiB
Python
"""Tests for the AHD-WW-TP1-22 range-hood profile."""
|
|
|
|
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 range_hood
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import (
|
|
BinarySensorDesc,
|
|
ButtonDesc,
|
|
FanDesc,
|
|
SelectDesc,
|
|
SwitchDesc,
|
|
)
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
def _hood():
|
|
resources = _load_device("range_hood")
|
|
info = resources["/information/vs/0"]
|
|
registry = for_device_by_model(
|
|
info["x.com.samsung.da.modelNum"],
|
|
info["x.com.samsung.da.description"],
|
|
)
|
|
return registry, resources
|
|
|
|
|
|
def _state():
|
|
registry, resources = _hood()
|
|
bound = discover(
|
|
resources,
|
|
registry.capabilities,
|
|
registry.pattern_capabilities,
|
|
)
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def test_ahd_model_resolves_to_range_hood_registry():
|
|
registry, _ = _hood()
|
|
assert registry is not None
|
|
assert registry.name == "range_hood"
|
|
|
|
|
|
def test_range_hood_has_no_unbound_hrefs():
|
|
registry, resources = _hood()
|
|
unbound = []
|
|
discover(
|
|
resources,
|
|
registry.capabilities,
|
|
registry.pattern_capabilities,
|
|
log=unbound.append,
|
|
)
|
|
assert unbound == []
|
|
|
|
|
|
def test_range_hood_fixture_values():
|
|
state = _state()
|
|
assert state["fan"] == "14"
|
|
assert state["alarm_code"] == "none"
|
|
assert state["automatic_operation"] is False
|
|
assert state["lamp"] is False
|
|
assert state["lamp_brightness"] == "2"
|
|
assert state["hood_filter_usage"] == 100
|
|
assert state["hood_filter_status"] == "wash"
|
|
assert state["energy_kwh"] == 165.62
|
|
assert state["clean_level"] == 2
|
|
assert state["dust"] == 18
|
|
assert state["fine_dust"] == 14
|
|
assert state["super_fine_dust"] == 9
|
|
assert state["periodic_air_sensing"] is False
|
|
assert state["air_sensing_state"] == "NonProcessing"
|
|
assert state["last_air_sensing_level"] == "Kr2"
|
|
assert state["after_run_active"] is False
|
|
assert state["after_run_progress"] == 0
|
|
|
|
|
|
def test_one_composite_fan_is_bound():
|
|
registry, resources = _hood()
|
|
bound = discover(
|
|
resources,
|
|
registry.capabilities,
|
|
registry.pattern_capabilities,
|
|
)
|
|
fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)]
|
|
assert len(fans) == 1
|
|
assert fans[0].href == "/hood/fanspeed/vs/0"
|
|
|
|
|
|
def test_fan_write_contract():
|
|
desc = next(e for e in range_hood.HOOD_FAN.entities if isinstance(e, FanDesc))
|
|
rep = {
|
|
"x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"],
|
|
}
|
|
assert desc.write_fn is not None
|
|
assert desc.write_fn(("power", True), rep) == (
|
|
["power", "0"],
|
|
{"value": True},
|
|
)
|
|
assert desc.write_fn(("power", False), rep) == (
|
|
["power", "0"],
|
|
{"value": False},
|
|
)
|
|
assert desc.write_fn(("power", True, "/power/vs/0"), rep) == (
|
|
["power", "vs", "0"],
|
|
{"x.com.samsung.da.power": "On"},
|
|
)
|
|
assert desc.write_fn(("power", False, "/power/vs/0"), rep) == (
|
|
["power", "vs", "0"],
|
|
{"x.com.samsung.da.power": "Off"},
|
|
)
|
|
assert desc.write_fn(("power", True, "/not-a-power-resource"), rep) is None
|
|
assert desc.write_fn(("speed", "16"), rep) == (
|
|
["hood", "fanspeed", "vs", "0"],
|
|
{"x.com.samsung.da.hood.fanSpeed": "16"},
|
|
)
|
|
assert desc.write_fn(("speed", "99"), rep) is None
|
|
|
|
rep_fallback = {
|
|
"x.com.samsung.da.hood.settableMinFanSpeed": "0",
|
|
"x.com.samsung.da.hood.settableMaxFanSpeed": "3",
|
|
}
|
|
assert desc.write_fn(("speed", "2"), rep_fallback) == (
|
|
["hood", "fanspeed", "vs", "0"],
|
|
{"x.com.samsung.da.hood.fanSpeed": "2"},
|
|
)
|
|
assert desc.write_fn(("speed", "4"), rep_fallback) is None
|
|
|
|
|
|
def test_lamp_write_contract():
|
|
lamp = next(e for e in range_hood.HOOD_LAMP.entities if isinstance(e, SwitchDesc))
|
|
brightness = next(e for e in range_hood.HOOD_LAMP.entities if isinstance(e, SelectDesc))
|
|
rep = {"x.com.samsung.lamp.range": ["1", "2"]}
|
|
assert lamp.write_fn is not None
|
|
assert brightness.write_fn is not None
|
|
assert lamp.write_fn("On", rep) == (
|
|
["hood", "lamp", "vs", "0"],
|
|
{"x.com.samsung.lamp.power": "On"},
|
|
)
|
|
assert lamp.write_fn("Off", rep) == (
|
|
["hood", "lamp", "vs", "0"],
|
|
{"x.com.samsung.lamp.power": "Off"},
|
|
)
|
|
assert brightness.write_fn("1", rep) == (
|
|
["hood", "lamp", "vs", "0"],
|
|
{"x.com.samsung.lamp.current": "1"},
|
|
)
|
|
assert brightness.write_fn("2", rep) == (
|
|
["hood", "lamp", "vs", "0"],
|
|
{"x.com.samsung.lamp.current": "2"},
|
|
)
|
|
assert brightness.write_fn("Unsupported", rep) is None
|
|
|
|
|
|
def test_after_run_cancel_write_contract():
|
|
"""issue #147: /afterrun/vs/0 was previously unbound. runningCancel's
|
|
only observed value is the command name itself, so cancel is modeled as
|
|
a ButtonDesc that writes that value back."""
|
|
cancel = next(e for e in range_hood.AFTER_RUN.entities if isinstance(e, ButtonDesc))
|
|
assert cancel.write_fn is not None
|
|
assert cancel.write_fn("Cancel", {}) == (
|
|
["afterrun", "vs", "0"],
|
|
{"x.com.samsung.da.runningCancel": "Cancel"},
|
|
)
|
|
|
|
|
|
def test_after_run_active_reads_on_off():
|
|
active = next(e for e in range_hood.AFTER_RUN.entities if isinstance(e, BinarySensorDesc))
|
|
assert active.value_fn is not None
|
|
assert active.value_fn("On") is True
|
|
assert active.value_fn("Off") is False
|