Some TP2X_WATERPURIFIER_20K units are coffee-capable and expose five resources issue #90's original dump never had: /favorite/coffee/vs/0, /favorite/hotwater/vs/0, /brand/recipe/info/vs/0, /coffee/custom/recipe/vs/0, /recipe/coffee/vs/0, and /recipe/coffee/deletion/vs/0. Adds FAVORITE_HOTWATER (a switch + select pair on /favorite/hotwater/vs/0, mirroring the existing FAVORITE_CAPACITY pattern) and COFFEE (a switch + status sensor on /favorite/coffee/vs/0). The remaining four hrefs are static capability-advertisement blobs or empty on every dump seen so far -- no live "current recipe" or "current custom slot" field to expose -- so they're added to the ignored coverage list per the 'don't guess' rule rather than modeled speculatively. Confirmed against the issue #107 diagnostics dump with zero unbound hrefs.
218 lines
8.1 KiB
Python
218 lines
8.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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Coffee-capable variant (issue #107) -- /favorite/coffee/vs/0 and
|
|
# /favorite/hotwater/vs/0, not present in issue #90's original dump.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _water_purifier_coffee():
|
|
resources = _load_device('water_purifier_coffee')
|
|
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_coffee():
|
|
reg, resources = _water_purifier_coffee()
|
|
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
|
|
|
|
|
def _state_coffee():
|
|
bound, resources = _bound_coffee()
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def _desc_coffee(key):
|
|
bound, _ = _bound_coffee()
|
|
return next(b.desc for b in bound if b.desc.key == key)
|
|
|
|
|
|
def test_coffee_variant_no_unbound_hrefs():
|
|
"""Every resource in the issue #107 dump binds or is covered, including
|
|
the four coffee-recipe hrefs not present in issue #90's dump."""
|
|
reg, resources = _water_purifier_coffee()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_coffee_variant_expected_state_keys_present():
|
|
state = _state_coffee()
|
|
for key in ('favorite_coffee_enabled', 'coffee_brew_status',
|
|
'favorite_hotwater_enabled', 'favorite_hotwater_temperature'):
|
|
assert key in state, key
|
|
|
|
|
|
def test_favorite_coffee_write_contract():
|
|
desc = _desc_coffee('favorite_coffee_enabled')
|
|
assert desc.write_fn('On', {}) == (
|
|
['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'On'})
|
|
assert desc.write_fn('Off', {}) == (
|
|
['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'})
|
|
|
|
|
|
def test_favorite_hotwater_write_contract():
|
|
enabled = _desc_coffee('favorite_hotwater_enabled')
|
|
assert enabled.write_fn('On', {}) == (
|
|
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
|
|
assert enabled.write_fn('Off', {}) == (
|
|
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'})
|
|
|
|
|
|
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
|
|
desc = _desc_coffee('favorite_hotwater_temperature')
|
|
assert desc.options_field == 'x.com.samsung.da.favorite.supportedList'
|
|
|
|
|
|
def test_coffee_recipe_hrefs_are_ignored_not_guessed():
|
|
"""Static capability-advertisement blobs or empty resources -- no live
|
|
'current recipe'/'current custom slot' field to expose, per the 'don't
|
|
guess' rule."""
|
|
from custom_components.localthings.registry.capabilities import water_purifier
|
|
ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE}
|
|
for href in ('/brand/recipe/info/vs/0', '/coffee/custom/recipe/vs/0',
|
|
'/recipe/coffee/vs/0', '/recipe/coffee/deletion/vs/0'):
|
|
assert href in ignored_hrefs, href
|