Skip to content

Commit 621aecf

Browse files
committed
Strengthen tests per review and extract reset for testability
- Extract _ws_loop reset block into _reset_for_reconnect so the test drives the real method rather than open-coding the same four statements: a regression in the production reset is now caught. - Parametrize state_reported wake test over both lu and lc, and assert the cached numeric value is preserved (keepalive carries no s). - Add test_unavailable_blocks_wait_for_message covering the consequence of a sensor going unavailable mid-stream: wait_for_message must block again instead of returning the cached "ready" flag.
1 parent d950bb6 commit 621aecf

2 files changed

Lines changed: 62 additions & 24 deletions

File tree

src/astrameter/powermeter/homeassistant.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,17 +127,21 @@ async def _ws_loop(self) -> None:
127127
raise
128128
except Exception as e:
129129
logger.error("Home Assistant WebSocket error: %s", e, exc_info=True)
130-
# Reset protocol state and invalidate cached values so
131-
# ``get_powermeter_watts`` raises (and ``wait_for_message``
132-
# blocks) until the reconnected ``subscribe_entities``
133-
# snapshot repopulates them.
134-
self._msg_id = 0
135-
self._subscribe_entities_id = None
136-
for eid in list(self._entity_values):
137-
self._entity_values[eid] = None
138-
self._entities_ready.clear()
130+
self._reset_for_reconnect()
139131
await asyncio.sleep(5)
140132

133+
def _reset_for_reconnect(self) -> None:
134+
"""Reset protocol state and invalidate cached values so
135+
``get_powermeter_watts`` raises (and ``wait_for_message`` blocks)
136+
until the reconnected ``subscribe_entities`` snapshot repopulates
137+
them.
138+
"""
139+
self._msg_id = 0
140+
self._subscribe_entities_id = None
141+
for eid in list(self._entity_values):
142+
self._entity_values[eid] = None
143+
self._entities_ready.clear()
144+
141145
def _handle_compressed_entity_event(self, ev: dict[str, Any]) -> None:
142146
"""Apply subscribe_entities payloads (initial + diffs)."""
143147
additions = ev.get("a")

src/astrameter/powermeter/homeassistant_test.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -490,16 +490,18 @@ async def test_entities_ready_cleared_when_value_becomes_none():
490490
# --- state_reported and reconnect behavior --------------------------------
491491

492492

493-
async def test_state_reported_event_wakes_wait_for_next_message():
493+
@pytest.mark.parametrize("ts_key", ["lu", "lc"])
494+
async def test_state_reported_event_wakes_wait_for_next_message(ts_key: str):
494495
"""HA's ``subscribe_entities`` omits ``s`` from the diff when a sensor
495496
is reported with an unchanged value (only ``lu``/``lc`` updates).
496497
``wait_for_next_message`` must still wake on those so callers like the
497498
Shelly emulator don't time out on constant sensors that the
498-
integration is still actively reporting.
499+
integration is still actively reporting — and the cached numeric value
500+
must remain unchanged (the keepalive carries no new ``s``).
499501
"""
500502
pm = _create_powermeter()
501503
await _simulate_auth_and_states(
502-
pm, [{"entity_id": "sensor.current_power", "state": "0"}]
504+
pm, [{"entity_id": "sensor.current_power", "state": "42"}]
503505
)
504506
pm._message_event.clear()
505507
waiter = asyncio.create_task(pm.wait_for_next_message(timeout=1))
@@ -512,12 +514,14 @@ async def test_state_reported_event_wakes_wait_for_next_message():
512514
{
513515
"type": "event",
514516
"event": {
515-
"c": {"sensor.current_power": {"+": {"lu": 1000.0}}},
517+
"c": {"sensor.current_power": {"+": {ts_key: 1000.0}}},
516518
},
517519
}
518520
),
519521
)
520522
await waiter # would raise TimeoutError if state_reported didn't wake it
523+
# Keepalive carries no ``s``; the cached value must be preserved.
524+
assert pm._entity_values["sensor.current_power"] == 42.0
521525

522526

523527
async def test_state_reported_before_initial_value_is_ignored():
@@ -547,26 +551,26 @@ async def test_state_reported_before_initial_value_is_ignored():
547551

548552

549553
async def test_reconnect_invalidates_cached_values():
550-
"""A websocket disconnect must invalidate cached values and clear the
551-
ready flag, so ``get_powermeter_watts`` raises and
552-
``wait_for_message`` blocks again until the reconnected
553-
``subscribe_entities`` snapshot arrives. Without this, callers would
554-
serve potentially-old values for the duration of the reconnect.
554+
"""A websocket disconnect must invalidate cached values, clear the
555+
ready flag, and reset the protocol counter so the reconnected
556+
``subscribe_entities`` snapshot is what callers see — not stale
557+
cache. Drives the real ``_reset_for_reconnect`` method that
558+
``_ws_loop`` invokes after a disconnect, so a regression in any of
559+
its four resets is caught here.
555560
"""
556561
pm = _create_powermeter()
557562
await _simulate_auth_and_states(
558563
pm, [{"entity_id": "sensor.current_power", "state": "100"}]
559564
)
565+
pm._subscribe_entities_id = 42 # non-default; the reset must clear it
560566
assert pm._entities_ready.is_set()
561567
assert await pm.get_powermeter_watts() == [100.0]
562568

563-
# Simulate the reset block in ``_ws_loop`` after a disconnect.
564-
pm._msg_id = 0
565-
pm._subscribe_entities_id = None
566-
for eid in list(pm._entity_values):
567-
pm._entity_values[eid] = None
568-
pm._entities_ready.clear()
569+
pm._reset_for_reconnect()
569570

571+
assert pm._msg_id == 0
572+
assert pm._subscribe_entities_id is None
573+
assert pm._entity_values["sensor.current_power"] is None
570574
assert not pm._entities_ready.is_set()
571575
with pytest.raises(ValueError):
572576
await pm.get_powermeter_watts()
@@ -576,3 +580,33 @@ async def test_reconnect_invalidates_cached_values():
576580
)
577581
assert pm._entities_ready.is_set()
578582
assert await pm.get_powermeter_watts() == [250.0]
583+
584+
585+
async def test_unavailable_blocks_wait_for_message():
586+
"""When a sensor transitions to ``unavailable`` mid-stream, the ready
587+
flag must clear so ``wait_for_message`` blocks again — callers
588+
waiting for a usable reading shouldn't see the immediate return
589+
they'd get from a fully-ready snapshot.
590+
"""
591+
pm = _create_powermeter()
592+
await _simulate_auth_and_states(
593+
pm, [{"entity_id": "sensor.current_power", "state": "100"}]
594+
)
595+
await pm.wait_for_message(timeout=1) # returns immediately when ready
596+
597+
ws = AsyncMock()
598+
await pm._handle_message(
599+
ws,
600+
json.dumps(
601+
{
602+
"type": "event",
603+
"event": {
604+
"c": {"sensor.current_power": {"+": {"s": "unavailable"}}},
605+
},
606+
}
607+
),
608+
)
609+
610+
assert pm._entity_values["sensor.current_power"] is None
611+
with pytest.raises(TimeoutError):
612+
await pm.wait_for_message(timeout=0.05)

0 commit comments

Comments
 (0)