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.
116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
"""Tests for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-ACMB1M,
|
|
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
|
|
controllable appliance state at all beyond the do-not-disturb window.
|
|
"""
|
|
|
|
from datetime import time as dt_time
|
|
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
def _air_monitor():
|
|
resources = _load_device("air_monitor")
|
|
reg = resolve(resources)
|
|
return reg, resources
|
|
|
|
|
|
def _bound():
|
|
reg, resources = _air_monitor()
|
|
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
|
|
|
|
|
def _state():
|
|
bound, resources = _bound()
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def _desc(key):
|
|
bound, _ = _bound()
|
|
return next(b.desc for b in bound if b.desc.key == key)
|
|
|
|
|
|
def test_resolves_to_air_monitor_registry():
|
|
reg, _ = _air_monitor()
|
|
assert reg is not None and reg.name == "air_monitor"
|
|
|
|
|
|
def test_resolves_via_oic_type_too():
|
|
"""This board's real dump carries /oic/d's device type
|
|
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
|
|
both paths must agree."""
|
|
reg = for_device_by_oic_type(("x.com.st.d.airqualitysensor",))
|
|
assert reg is not None and reg.name == "air_monitor"
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
reg, resources = _air_monitor()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_air_quality_sensors_read_from_shared_items_decode():
|
|
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
|
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
|
|
which neither air_purifier nor range_hood report."""
|
|
state = _state()
|
|
assert state["dust"] == 31
|
|
assert state["fine_dust"] == 23
|
|
assert state["super_fine_dust"] == 18
|
|
assert state["odor"] == 1
|
|
assert state["clean_level"] == 2
|
|
assert state["co2"] == 498
|
|
|
|
|
|
def test_second_value_slot_is_not_exposed():
|
|
"""Dust/fine_dust/super_fine_dust each carry a second `value[1]` grade
|
|
code (e.g. Dust's ['31', '2']) with no supported-values field anywhere
|
|
in the dump to explain its scale -- per the 'still never invent... from
|
|
nothing' rule, only the raw reading (value[0]) is bound, not a second
|
|
'dust_level' or similar entity for that code."""
|
|
state = _state()
|
|
assert "dust_level" not in state
|
|
assert "dust_grade" not in state
|
|
|
|
|
|
def test_humidity_and_battery_present():
|
|
state = _state()
|
|
assert state["humidity"] == 57
|
|
assert state["battery"] == 80
|
|
assert state["battery_charging"] is False
|
|
|
|
|
|
def test_air_quality_standard_present():
|
|
state = _state()
|
|
assert state["air_quality_standard"] == "AirCleanAssociation"
|
|
|
|
|
|
def test_dnd_read_contract():
|
|
state = _state()
|
|
assert state["dnd"] is False
|
|
assert state["dnd_start"] == dt_time(14, 0)
|
|
assert state["dnd_end"] == dt_time(22, 0)
|
|
|
|
|
|
def test_dnd_write_contracts_are_flagged_educated_guesses():
|
|
"""Issue #210: no idle-vs-active dump pair exists for /dnd/vs/0 (DND
|
|
was never toggled in the one dump this board has), so these write
|
|
contracts are educated guesses -- symmetric with the read side's own
|
|
string/time-format shape, not invented from nothing, but still
|
|
unconfirmed on real hardware. Only the wiring is tested here; whether
|
|
the device actually accepts these writes needs a reporter to confirm."""
|
|
dnd = _desc("dnd")
|
|
path, body = dnd.write_fn("On", {})
|
|
assert path == ["dnd", "vs", "0"]
|
|
assert body == {"x.com.samsung.da.value": "true"}
|
|
path, body = dnd.write_fn("Off", {})
|
|
assert body == {"x.com.samsung.da.value": "false"}
|
|
|
|
start = _desc("dnd_start")
|
|
path, body = start.write_fn(dt_time(9, 30), {})
|
|
assert path == ["dnd", "vs", "0"]
|
|
assert body == {"x.com.samsung.da.startTime": "09:30:00"}
|