The TP2X_WATERPURIFIER_20K water purifier reports no oneUiVersion and its modelNum/description don't match any consumer-prefix or existing board-family token, so it fell into the unknown-device-type fallback with only common capabilities. Add a new water_purifier registry, routed via a 'WATERPURIFIER' modelNum/description fallback rule. Models dispense settings (type/temperature/capacity/pouring status), sterilize and filter status, favorite-capacity presets, and the three water/buzzer locks. Per the adding-device-support skill's "never hard-code the one dump's values" rule: dispense-capacity bounds and step come live from the device's own desiredCapacityRange/ capacityResolution fields (range_field/step_fn), not a hardcoded constant, and the hot-water-temperature control is a select over the live supportedHotTemperatures list rather than a number with invented bounds, since only a few discrete temperatures are selectable. /mode/vs/0 and /automation/waterpurifier/vs/0 are left unmodeled: the former carries an opaque wizard-workflow token with no coherent current-value contract, the latter is a static support-flags blob with no live setting to expose. Confirmed against the issue #90 diagnostics dump with zero unbound hrefs. Updates the README's supported-appliance-types table for the new device type.
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""Tests for Samsung water-purifier support (issue #90, TP2X_WATERPURIFIER_20K).
|
|
|
|
HA-free like the rest of the suite: exercises the registry, discovery/
|
|
flatten, and the 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
|
|
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
def _water_purifier():
|
|
resources = _load_device('water_purifier')
|
|
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 = _water_purifier()
|
|
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_water_purifier_registry():
|
|
reg, _ = _water_purifier()
|
|
assert reg is not None and reg.name == 'water_purifier'
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
"""Every resource in the issue #90 dump binds or is covered -- clears
|
|
the coverage-gap repair."""
|
|
reg, resources = _water_purifier()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_expected_state_keys_present():
|
|
state = _state()
|
|
for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity',
|
|
'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status',
|
|
'hotwater_lock', 'coldwater_lock', 'buzz_lock'):
|
|
assert key in state, key
|
|
|
|
|
|
def test_dispense_type_options_come_from_live_supported_types():
|
|
desc = _desc('dispense_type')
|
|
assert desc.options_field == 'x.com.samsung.da.supportedTypes'
|
|
|
|
|
|
def test_dispense_type_write_contract():
|
|
desc = _desc('dispense_type')
|
|
path, body = desc.write_fn('hotwater', {})
|
|
assert path == ['setting', 'waterpurifier', 'vs', '0']
|
|
assert body == {'x.com.samsung.da.desiredType': 'hotwater'}
|
|
|
|
|
|
def test_hot_water_temperature_is_a_select_not_a_number():
|
|
"""Only a handful of discrete temperatures are selectable (not a
|
|
continuous range) -- confirmed by supportedHotTemperatures being a short
|
|
enumerated list, not a [min, max] range field."""
|
|
desc = _desc('hot_water_temperature')
|
|
assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures'
|
|
|
|
|
|
def test_dispense_capacity_bounds_come_live_not_hardcoded():
|
|
"""Bounds and step come from the device's own desiredCapacityRange/
|
|
capacityResolution fields, not a hardcoded constant -- see the
|
|
adding-device-support skill's 'never hard-code the one dump's values'
|
|
section."""
|
|
desc = _desc('dispense_capacity')
|
|
assert isinstance(desc, NumberDesc)
|
|
assert desc.native_min is None
|
|
assert desc.native_max is None
|
|
assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange'
|
|
rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'],
|
|
'x.com.samsung.da.capacityResolution': '10'}
|
|
assert desc.step_fn(rep) == 10
|
|
|
|
|
|
def test_dispense_capacity_write_contract():
|
|
desc = _desc('dispense_capacity')
|
|
path, body = desc.write_fn('550', {})
|
|
assert path == ['setting', 'waterpurifier', 'vs', '0']
|
|
assert body == {'x.com.samsung.da.desiredCapacity': '550'}
|
|
|
|
|
|
def test_lock_switches_read_unlocked_as_off():
|
|
state = _state()
|
|
assert state['hotwater_lock'] is False
|
|
assert state['coldwater_lock'] is False
|
|
assert state['buzz_lock'] is False
|
|
|
|
|
|
def test_lock_switch_write_contracts():
|
|
hot = _desc('hotwater_lock')
|
|
cold = _desc('coldwater_lock')
|
|
buzz = _desc('buzz_lock')
|
|
assert hot.write_fn('On', {}) == (
|
|
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'})
|
|
assert cold.write_fn('Off', {}) == (
|
|
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'})
|
|
assert buzz.write_fn('On', {}) == (
|
|
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'})
|
|
|
|
|
|
def test_favorite_capacity_options_come_from_live_capacity_list():
|
|
desc = _desc('favorite_capacity')
|
|
assert desc.options_field == 'x.com.samsung.da.capacityList'
|
|
|
|
|
|
def test_sterilize_timestamps_parsed_as_utc():
|
|
state = _state()
|
|
assert state['sterilize_last_time'].tzinfo is not None
|
|
assert state['sterilize_plan_time'].tzinfo is not None
|
|
|
|
|
|
def test_mode_hrefs_are_ignored_not_guessed():
|
|
"""/mode/vs/0's supportedModes carries a single opaque wizard token and
|
|
modes reports an unrelated value not even in supportedModes -- internal
|
|
plumbing, left unmodeled per the 'don't guess' rule rather than exposed
|
|
as a nonsensical select."""
|
|
from custom_components.localthings.registry.capabilities import water_purifier
|
|
ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE}
|
|
assert '/mode/vs/0' in ignored_hrefs
|
|
assert '/automation/waterpurifier/vs/0' in ignored_hrefs
|