Files
localthings/tests/test_ehs_capabilities.py
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
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.
2026-08-02 23:56:38 +00:00

218 lines
7.3 KiB
Python

"""Tests for Samsung EHS (Eco Heating System) heat pump support
(TP1X_DA_AC_EHS_01001_0000).
HA-free like the rest of the suite: exercises the registry, discovery/
flatten, and the zone/dhw mode and temperature write contracts.
"""
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.discovery import discover
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, WaterHeaterDesc
from tests.conftest import _load_device
def _ehs():
resources = _load_device("ehs")
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 _bound():
reg, resources = _ehs()
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_model_resolves_to_ehs_registry():
reg, _ = _ehs()
assert reg is not None and reg.name == "ehs"
def test_no_unbound_hrefs():
"""Every resource in the real TP1X_DA_AC_EHS_01001_0000 dump binds or is
covered -- clears the coverage-gap repair a device_type='unknown' entry
raises."""
reg, resources = _ehs()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_state_keys_present():
state = _state()
for key in (
"zone_power",
"zone_mode",
"zone_temperature",
"zone_target_temperature",
"water_heater",
"away_mode",
"mute_once",
"alarm_code",
"energy_kwh",
):
assert key in state, key
def test_zone_temperature_reads_current_value():
state = _state()
assert state["zone_temperature"] == 30.0
def test_zone_target_temperature_reads_desired_value():
state = _state()
assert state["zone_target_temperature"] == 5.0
def test_zone_mode_reads_first_mode():
state = _state()
assert state["zone_mode"] == "Cool"
def test_zone_mode_select_options_come_from_live_supported_modes():
"""Options are read live from x.com.samsung.da.supportedModes, not a
hardcoded tuple -- so a future firmware with a different mode set is
handled automatically."""
desc = _desc("zone_mode")
assert isinstance(desc, SelectDesc)
assert desc.options_field == "x.com.samsung.da.supportedModes"
assert desc.options == ()
def test_zone_mode_write_contract():
desc = _desc("zone_mode")
path, body = desc.write_fn("Heat", {})
assert path == ["mode", "vs", "0"]
assert body == {"x.com.samsung.da.modes": ["Heat"]}
def test_zone_power_reads_off():
state = _state()
assert state["zone_power"] is False
def test_zone_power_write_contract():
desc = _desc("zone_power")
path, body = desc.write_fn("On", {})
assert path == ["power", "vs", "0"]
assert body == {"x.com.samsung.da.power": "On"}
def test_zone_target_temperature_write_contract():
desc = _desc("zone_target_temperature")
assert isinstance(desc, NumberDesc)
path, body = desc.write_fn("21.5", {})
assert path == ["temperatures", "indoor", "vs", "0"]
assert body == {"x.com.samsung.da.desired": "21.5"}
def test_zone_target_temperature_bounds_read_live():
"""min/max/step come from the device's own resource fields rather than
a hardcoded constant -- see the adding-device-support skill's 'never
hard-code the one dump's values' section."""
desc = _desc("zone_target_temperature")
rep = {"x.com.samsung.da.minimum": "5.0", "x.com.samsung.da.maximum": "25.0"}
assert desc.native_min_fn(rep) == 5.0
assert desc.native_max_fn(rep) == 25.0
assert desc.step_fn({"x.com.samsung.da.increment": "0.5"}) == 0.5
# No live field: falls back to a sane default rather than raising.
assert desc.native_min_fn({}) == 5.0
assert desc.native_max_fn({}) == 30.0
assert desc.step_fn({}) == 0.5
def test_zone_target_temperature_bounds_fall_back_together():
"""One end without the other is not a usable range: pairing a real
device minimum with an invented default maximum looks plausible and is
silently wrong, so a half-reported range falls back whole."""
desc = _desc("zone_target_temperature")
half = {"x.com.samsung.da.minimum": "10.0"}
assert desc.native_min_fn(half) == 5.0
assert desc.native_max_fn(half) == 30.0
def test_zone_target_temperature_zero_increment_is_not_collapsed():
"""`or` would turn a genuine 0 into the 0.5 default (issue #160)."""
desc = _desc("zone_target_temperature")
assert desc.step_fn({"x.com.samsung.da.increment": "0"}) == 0.0
def test_water_heater_entity_is_bound():
"""The composite water_heater entity binds the primary /mode/dhw/vs/0
resource -- same primary-plus-siblings shape as the AC's ClimateDesc
(see test_airconditioner_capabilities.py's test_climate_entity_is_bound)."""
bound, _ = _bound()
water_heaters = [b for b in bound if isinstance(b.desc, WaterHeaterDesc)]
assert len(water_heaters) == 1
assert water_heaters[0].href == "/mode/dhw/vs/0"
def test_water_heater_reads_first_mode():
"""The flattened/golden state exposes the same representative scalar
the entity's current_operation is derived from -- see climate.py's
_first_mode for the identical pattern on the AC side."""
state = _state()
assert state["water_heater"] == "Eco"
def test_water_heater_write_targets():
"""DHW.entities[0].write_fn maps each (kind, value) command to the right
vendor POST target and body -- power, mode and temperature only, no fan/
swing/preset (the AC's climate.py has those; the DHW loop doesn't)."""
write = _desc("water_heater").write_fn
assert write(("power", True), {}) == (
["power", "dhw", "vs", "0"],
{"x.com.samsung.da.power": "On"},
)
assert write(("power", False), {}) == (
["power", "dhw", "vs", "0"],
{"x.com.samsung.da.power": "Off"},
)
assert write(("mode", "Force"), {}) == (
["mode", "dhw", "vs", "0"],
{"x.com.samsung.da.modes": ["Force"]},
)
assert write(("temperature", 45.0), {}) == (
["temperatures", "dhw", "vs", "0"],
{"x.com.samsung.da.desired": "45.0"},
)
assert write(("bogus", 1), {}) is None
def test_dhw_power_and_temperature_declared_as_coverage():
"""/power/dhw/vs/0 and /temperatures/dhw/vs/0 are read by the composite
water_heater entity (via water_heater.py's sibling reads), not given
their own entities -- declared as no-entity coverage caps so discover()
reports no gap, same pattern as the AC's CLIMATE_CONSUMED_HREFS."""
reg, _ = _ehs()
for href in ("/power/dhw/vs/0", "/temperatures/dhw/vs/0"):
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
def test_away_mode_reads_off():
state = _state()
assert state["away_mode"] is False
def test_away_mode_write_contract():
desc = _desc("away_mode")
path, body = desc.write_fn("On", {})
assert path == ["option", "outgoing", "vs", "0"]
assert body == {"x.com.samsung.da.away": "On"}