Fix hotwater_lock key-collision hazard, address Opus review of triage fixes
An Opus review of the previous commit found that giving the switchHotwater fallback and LOCK.hotwater_lock the same key introduced a real bug: adapter.flatten() (the source of coordinator.data, which every switch's is_on reads) only ever honours exists_fn, never entity.py's implicit own-field-presence default that gates plain registration. With only one side of the pair gated, both descriptors still wrote the same key into the flattened state dict, and whichever was processed last -- decided by device-reported href order, not correctness -- silently won. Reproduced with the existing coffee fixture: reversing resource order flipped hotwater_lock from correct (False) to a stuck True. Fixed by gating both sides symmetrically via a shared tri-state helper that also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather than "confirmed absent" -- otherwise the stub window let both descriptors pass exists_fn at once, which would have registered two switch entities with the same unique_id. The fallback also re-asserts its own field's presence, a check it used to get for free before it shared LOCK's key. Also addresses two smaller findings from the same review, both in the range-hood after-run capability (#147): runningProgress's unit='%' was a guess from a single "0" sample with no supported-values/range field to confirm the domain -- inconsistent with treating activationState as read-only for the same "don't guess" reason -- so it's now a bare passthrough sensor; and entity_category='diagnostic' was dropped from the two read entities since after-run is a feature the user actively watches and cancels via the (correctly uncategorized) button, not passive diagnostics.
This commit is contained in:
4 files changed
+120
-21
No files matched your search
@@ -62,7 +62,7 @@ def test_range_hood_fixture_values():
|
||||
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
|
||||
assert state['after_run_progress'] == '0'
|
||||
|
||||
|
||||
def test_one_composite_fan_is_bound():
|
||||
|
||||
@@ -216,14 +216,69 @@ def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag():
|
||||
assert lock.value_fn('Locked') is True
|
||||
|
||||
|
||||
def test_favorite_hotwater_lock_fallback_only_activates_when_primary_absent():
|
||||
"""The switchHotwater-based lock and LOCK's hotwaterLock-based lock share
|
||||
the 'hotwater_lock' key so only one entity is ever registered (issue
|
||||
#144). This fixture has no hotwaterLock field, so the fallback must be
|
||||
active; a board reporting both must not."""
|
||||
lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
|
||||
assert lock.exists_fn({}, {'/status/lock/vs/0': {}}) is True
|
||||
assert lock.exists_fn({}, {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}) is False
|
||||
def test_favorite_hotwater_lock_wins_in_flattened_state():
|
||||
"""adapter.flatten() -- the actual source of coordinator.data every
|
||||
switch's is_on reads -- only honours exists_fn, never entity.py's
|
||||
implicit own-field-presence default. So it's not enough for the
|
||||
*registered* entity to resolve correctly (test_expected_state_keys_present
|
||||
territory); the shared 'hotwater_lock' key in the flattened dict itself
|
||||
must reflect the live switchHotwater value, not a stale phantom from
|
||||
LOCK's ungated hotwaterLock read (issue #144). This fixture's
|
||||
switchHotwater reads 'Unlocked'; without exists_fn on *both* sides of the
|
||||
pair, LOCK's descriptor computes None != 'Unlocked' == True regardless,
|
||||
and flatten() would pick whichever of the two entities happens to be
|
||||
processed last."""
|
||||
state = _state_coffee()
|
||||
assert state['hotwater_lock'] is False
|
||||
|
||||
|
||||
def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state():
|
||||
"""Both LOCK.hotwater_lock and FAVORITE_HOTWATER's switchHotwater
|
||||
fallback are always bound on this fixture (their hrefs are both always
|
||||
present) -- discrimination happens entirely in exists_fn. Exactly one of
|
||||
the two must ever pass, regardless of iteration order, or two switch
|
||||
entities would be registered with the same unique_id."""
|
||||
bound, resources = _bound_coffee()
|
||||
candidates = [b for b in bound if b.desc.key == 'hotwater_lock']
|
||||
assert len(candidates) == 2
|
||||
included = [b for b in candidates
|
||||
if b.desc.exists_fn(resources.get(b.href) or {}, resources)]
|
||||
assert len(included) == 1
|
||||
assert included[0].href == '/favorite/hotwater/vs/0'
|
||||
|
||||
|
||||
def test_hotwater_lock_fallback_gating_across_status_lock_states():
|
||||
"""The fallback (FAVORITE_HOTWATER's switchHotwater descriptor) must
|
||||
activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock --
|
||||
never while that resource is an unfetched stub ({}), and never when it
|
||||
does carry the field. LOCK's own descriptor is the mirror image."""
|
||||
fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
|
||||
primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0')
|
||||
own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'}
|
||||
|
||||
# /status/lock/vs/0 absent entirely -- device genuinely lacks it.
|
||||
assert fallback.exists_fn(own_rep, {}) is True
|
||||
|
||||
# /status/lock/vs/0 present but not yet fetched (a stub): outcome
|
||||
# pending, so the fallback must defer to LOCK rather than assume absence.
|
||||
stub_resources = {'/status/lock/vs/0': {}}
|
||||
assert fallback.exists_fn(own_rep, stub_resources) is False
|
||||
assert primary.exists_fn({}, stub_resources) is True
|
||||
|
||||
# /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this
|
||||
# fixture's actual shape) -- the fallback wins.
|
||||
absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}}
|
||||
assert fallback.exists_fn(own_rep, absent_resources) is True
|
||||
assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False
|
||||
|
||||
# /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins.
|
||||
present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}
|
||||
assert fallback.exists_fn(own_rep, present_resources) is False
|
||||
assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True
|
||||
|
||||
# The fallback also re-asserts its own field, since it no longer gets
|
||||
# that check for free once it shares LOCK's key (issue #144 review).
|
||||
assert fallback.exists_fn({}, {}) is False
|
||||
|
||||
|
||||
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
|
||||
|
||||
Reference in new issue
Block a user