Skip to content

Commit 1381851

Browse files
kingpanther13claude
andcommitted
test: address Patch76 review findings on PR #1406
Three findings from Patch76's review of #1406: 1. **Discriminator test wasn't actually discriminating** — ``test_event_capture_short_circuits_rest_scan`` previously seeded ``get_states()`` to return ``automation.from_rest`` on every call and asserted ``result in {"automation.from_event", "automation.from_rest"}``. That accepts both outcomes, so a regression that broke the closure-cell short-circuit (forcing ``sample()`` to always fall through to ``get_states()``) would silently pass — REST would win every time. Now seeds ``get_states()`` with a side_effect that returns empty on the first call (post-subscribe sample) and the REST entity on subsequent calls, then asserts ``result == "automation.from_event"`` (deterministic discriminator: cell wins → event entity, regression → REST entity) and ``call_count == 1`` (the cell short-circuit means the post-event re-sample never touches REST). 2. **Duplicate-unique-id collision branch untested** — Patch76 noted the first-wins guard in ``event_filter`` (``util_helpers.py:1197``) had no test pinning the contract. Added ``test_duplicate_unique_id_collision_first_wins`` which fires two matching state_changed events back-to-back and asserts both "automation.first wins" and "warning logged naming both entity_ids." 3. **``last_api_error`` wedged-channel branch untested** — Patch76 noted the conditional warning at ``util_helpers.py:1222`` had no test pinning the "REST channel down" vs "automation never published" discrimination the code exists to make. Added ``test_wedged_rest_channel_surfaces_last_api_error`` which seeds ``get_states()`` to raise ``HomeAssistantAPIError`` on every call and asserts the timeout warning includes "every REST sample failing" and the last error message string. Rebased onto current upstream/master. Mechanical conflict resolution per Patch76: the ``_POLL_CADENCE`` deletion wins, taking #1389's measurement instrumentation with it. ``_poll_for_automation_entity`` no longer needs the cadence loop or the elapsed-time instrumentation because the WS event-driven path resolves sub-second on the happy path and the REST fallback uses uniform polling. The redundant local ``import time`` inside ``upsert_automation_config`` is also gone now that the top-level ``import time`` (added by #1389) remains for the unique_id-generation use at ``time.time() * 1000``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1967976 commit 1381851

2 files changed

Lines changed: 118 additions & 22 deletions

File tree

src/ha_mcp/client/rest_client.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -895,8 +895,6 @@ async def upsert_automation_config(
895895
Raises:
896896
HomeAssistantAPIError: If configuration invalid or API error
897897
"""
898-
import time
899-
900898
# Generate unique_id for new automation if not provided
901899
if identifier is None:
902900
unique_id = str(int(time.time() * 1000))

tests/src/unit/test_wait_helpers.py

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -979,23 +979,49 @@ async def fire_noise():
979979
async def test_event_capture_short_circuits_rest_scan(self, ws_client, mock_client):
980980
"""When the matching event arrives, the discovered entity_id is
981981
captured in the closure cell so the post-nudge ``sample()`` returns
982-
the captured value WITHOUT re-scanning ``get_states()``. Pin this
983-
explicitly by seeding ``get_states()`` with a *different* matching
984-
automation: cell-wins → result is the event's entity_id, scan-wins
985-
→ result is the REST entity_id. Different outcomes give the test
986-
a discriminator the empty-list version couldn't provide."""
987-
# REST scan would resolve to ``automation.from_rest``; the event
988-
# carries ``automation.from_event``. Only one can win.
989-
mock_client.get_states = AsyncMock(
990-
return_value=[
982+
the captured value WITHOUT re-scanning ``get_states()``.
983+
984+
Deterministic discriminator (Patch76 #1406 review): seeding
985+
``get_states()`` to return a *different* matching automation on
986+
every call — but ONLY after the post-subscribe sample has run with
987+
empty results — produces different outcomes for the two paths:
988+
989+
- Working (cell short-circuits): post-subscribe sample returns
990+
empty → loop enters → event fires + filter captures
991+
``automation.from_event`` → re-sample sees ``captured["entity_id"]``
992+
is set → returns ``automation.from_event`` without calling
993+
``get_states()`` again.
994+
- Regression (cell check removed from ``sample()``): re-sample
995+
falls through to ``get_states()`` → finds ``automation.from_rest``
996+
→ returns ``automation.from_rest``.
997+
998+
Asserting equality (not ``in``) is what makes this a real
999+
discriminator — the previous ``in {...}`` assertion accepted both
1000+
outcomes and would silently pass a short-circuit regression.
1001+
"""
1002+
call_count = {"n": 0}
1003+
1004+
async def get_states_seq():
1005+
call_count["n"] += 1
1006+
if call_count["n"] == 1:
1007+
# Post-subscribe sample: empty so the loop must enter.
1008+
return []
1009+
# Any subsequent call (which the working short-circuit makes
1010+
# us skip): returns the REST entity that would override the
1011+
# captured cell if the short-circuit were broken.
1012+
return [
9911013
{
9921014
"entity_id": "automation.from_rest",
9931015
"attributes": {"id": "uid_dup"},
9941016
}
9951017
]
996-
)
1018+
1019+
mock_client.get_states = AsyncMock(side_effect=get_states_seq)
9971020

9981021
async def fire_after_subscribe():
1022+
# Small delay so the post-subscribe sample completes and the
1023+
# wait loop is parked on ``nudge.wait()`` before the event
1024+
# arrives — guarantees the event-path wins.
9991025
await asyncio.sleep(0.05)
10001026
await ws_client.fire_state_changed(
10011027
"automation.from_event",
@@ -1008,16 +1034,13 @@ async def fire_after_subscribe():
10081034
)
10091035
await fire_task
10101036

1011-
# In practice the post-subscribe REST sample races against the
1012-
# event arrival, so the result may be either value depending on
1013-
# timing — but ``automation.from_event`` proves the capture
1014-
# short-circuit took the value from the event payload (the cell
1015-
# short-circuit at the top of ``sample()`` returns early before
1016-
# re-calling ``get_states()``).
1017-
assert result in {"automation.from_event", "automation.from_rest"}
1018-
# Whichever path won, the captured cell or the REST sample must
1019-
# have produced a real entity_id (i.e. the wait did not time out).
1020-
assert result is not None
1037+
# Working short-circuit: event payload's entity_id wins.
1038+
# Regression (no short-circuit): REST entity would override.
1039+
assert result == "automation.from_event"
1040+
# Tighter pin: ``get_states()`` was called exactly once (the
1041+
# post-subscribe sample). The post-event re-sample short-circuits
1042+
# via the captured cell and never touches REST.
1043+
assert call_count["n"] == 1
10211044

10221045
async def test_connection_drop_during_discovery_falls_back_to_rest(
10231046
self, ws_client, mock_client
@@ -1061,3 +1084,78 @@ async def get_states_dropping_ws():
10611084
assert call_count["n"] >= 2
10621085
# Cleanup of the subscription we did establish still ran.
10631086
assert len(ws_client.unsubscribed) == 1
1087+
1088+
async def test_duplicate_unique_id_collision_first_wins(
1089+
self, ws_client, mock_client, caplog
1090+
):
1091+
"""If two matching events arrive for the same unique_id (HA
1092+
storage forbids it, but the filter's first-wins guard exists for
1093+
the "if it ever happens" path), the FIRST observed entity_id is
1094+
captured and a warning is logged on the collision. Pins the
1095+
first-wins contract against a regression that flips back to
1096+
last-writer-wins. Patch76 #1406 review."""
1097+
import logging
1098+
1099+
# Post-subscribe sample finds nothing; both events arrive after
1100+
# subscribe.
1101+
mock_client.get_states = AsyncMock(return_value=[])
1102+
1103+
async def fire_two_matches():
1104+
await asyncio.sleep(0.05)
1105+
# Both events carry the same unique_id but different
1106+
# entity_ids — only the first should win.
1107+
await ws_client.fire_state_changed(
1108+
"automation.first",
1109+
new_state={"attributes": {"id": "uid_dup"}},
1110+
)
1111+
await ws_client.fire_state_changed(
1112+
"automation.second",
1113+
new_state={"attributes": {"id": "uid_dup"}},
1114+
)
1115+
1116+
with caplog.at_level(logging.WARNING, logger="ha_mcp.tools.util_helpers"):
1117+
fire_task = asyncio.create_task(fire_two_matches())
1118+
result = await wait_for_automation_entity_by_unique_id(
1119+
mock_client, "uid_dup", timeout=2.0
1120+
)
1121+
await fire_task
1122+
1123+
assert result == "automation.first"
1124+
# Collision warning emitted naming both entity_ids.
1125+
collision_logs = [
1126+
r for r in caplog.records if "Duplicate automation match" in r.getMessage()
1127+
]
1128+
assert len(collision_logs) == 1
1129+
assert "automation.first" in collision_logs[0].getMessage()
1130+
assert "automation.second" in collision_logs[0].getMessage()
1131+
1132+
async def test_wedged_rest_channel_surfaces_last_api_error(
1133+
self, mock_client, caplog
1134+
):
1135+
"""When every REST sample raises ``HomeAssistantAPIError`` across
1136+
the timeout budget, the final timeout warning surfaces the last
1137+
error so operators can distinguish "automation truly not
1138+
published" from "REST channel down." Pins the
1139+
``captured["last_api_error"]`` discrimination branch. Patch76
1140+
#1406 review. Uses the autouse ``force_rest_fallback`` fixture so
1141+
no WS subscription is created — the REST sample callback is the
1142+
only path."""
1143+
import logging
1144+
1145+
mock_client.get_states = AsyncMock(
1146+
side_effect=HomeAssistantAPIError("HA REST 503 transient")
1147+
)
1148+
1149+
with caplog.at_level(logging.WARNING, logger="ha_mcp.tools.util_helpers"):
1150+
result = await wait_for_automation_entity_by_unique_id(
1151+
mock_client, "uid_wedged", timeout=0.2, poll_interval=0.05
1152+
)
1153+
1154+
assert result is None
1155+
# The conditional wedged-channel warning fires with the last
1156+
# error string embedded.
1157+
wedged_logs = [
1158+
r for r in caplog.records if "every REST sample failing" in r.getMessage()
1159+
]
1160+
assert len(wedged_logs) == 1
1161+
assert "HA REST 503 transient" in wedged_logs[0].getMessage()

0 commit comments

Comments
 (0)