Add write_resource/read_resource services for probing write contracts (issue #300)
The options-flow "Debug write" panel could only ever do one write to one href per pass -- not enough for the issue #300 wall oven, whose board discards settings writes while idle and only keeps them once a cycle is already running. Finding what starts a cycle needs an ordered sequence of writes across resources, with real settle delays between them, and a way to check afterward whether anything actually held. - coordinator.py: async_raw_write_sequence owns a whole ordered sequence under one _session_lock hold (so a poll can't interleave mid-sequence), with per-step settle and an optional delayed verify_after re-read done outside the lock. async_raw_write is now a one-item wrapper over it, so tests/test_coordinator_raw_write.py keeps passing unmodified. Also adds async_raw_read, a live GET bypassing the cache -- staleness is exactly what makes revert-testing unreliable. - services.py (new): the two HA services. Device-target resolution scans loaded coordinators' MAIN/subdevice identifiers and requires exactly one match, so an area/label target can't silently fan a raw write out across several appliances. Canonical->actual href translation happens here, not in the coordinator, which stays subdevice-agnostic. - services.yaml (new): selectors/descriptions for both services, inline per HA's custom-integration support -- keeps translations/en.json's mirror test (test_translations.py) green without touching all 6 languages for a services block. New exception keys (write caps, device target resolution) still went into translations/*.json's existing exceptions section, mirrored across all 6 languages. - __init__.py: adds async_setup to register the services once, process-wide. - config_flow.py: the debug panel's async_step_debug_edit now calls write_resource instead of coord.async_raw_write directly, so there is exactly one code path that performs a raw write. - README.md: new Part 5 documenting both services, with a worked write_resource example; points the capability-gap section at them. tests/test_services.py (new): sequencing/ordering, settle timing, changed vs. held (the reverted case is issue #300's own symptom), exactly-one- device resolution, subdevice href translation, validation caps, and the options-flow panel end to end through the service.
This commit is contained in:
15 files changed
+1180
-27
No files matched your search
@@ -1036,9 +1036,10 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
hass: HomeAssistant,
|
||||
mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Picking an href, then submitting a payload, drives
|
||||
coordinator.async_raw_write and lands on the result menu with the
|
||||
device's response."""
|
||||
"""Picking an href, then submitting a payload, calls the write_resource
|
||||
service (issue #300) -- which drives
|
||||
coordinator.async_raw_write_sequence -- and lands on the result menu
|
||||
with the device's response."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
@@ -1056,8 +1057,20 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
assert result["step_id"] == "debug_edit"
|
||||
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -1123,8 +1136,20 @@ async def test_options_flow_finish_preserves_existing_options(
|
||||
user_input={"href": "/washer/vs/0"},
|
||||
)
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Tests for the write_resource/read_resource services (issue #300) and the
|
||||
options-flow debug panel now that it goes through write_resource instead of
|
||||
calling coordinator.async_raw_write directly (config_flow.py). See
|
||||
tests/test_coordinator_raw_write.py for the underlying single-write
|
||||
primitive's own session/validation tests -- these focus on the service
|
||||
layer: device target resolution, subdevice href translation, ordered
|
||||
multi-write sequencing, settle/verify_after, and the options-flow rewiring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
from custom_components.localthings.services import async_setup_services
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: "10.0.0.199",
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
||||
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
_SLEEP_TARGET = "custom_components.localthings.coordinator.asyncio.sleep"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stand-in for DtlsCoapSession: records every POST verbatim and answers
|
||||
GET from a per-href queue of canned representations, so a test can model
|
||||
a value that changes across successive reads of the same href (the
|
||||
write's own follow-up GET vs. a later verify_after re-read) -- no real
|
||||
DTLS/network involved. Modeled on test_coordinator_raw_write.py's
|
||||
_FakeRawWriteSession, extended for multi-step sequences."""
|
||||
|
||||
def __init__(self, post_code: int = 0x44):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_reps: dict[str, list[dict]] = {}
|
||||
|
||||
def queue_get(self, href: str, rep: dict) -> None:
|
||||
"""Queue one more canned rep for `href`'s next GET. Once an href's
|
||||
queue is down to one entry, that entry keeps answering every
|
||||
further GET -- a test only needs to queue the values that
|
||||
actually change across calls."""
|
||||
self._get_reps.setdefault(href.strip("/"), []).append(rep)
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return self._post_code, b""
|
||||
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
key = "/".join(path_segs)
|
||||
queue = self._get_reps.get(key)
|
||||
if not queue:
|
||||
return 0x45, cbor2.dumps({})
|
||||
rep = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
return 0x45, cbor2.dumps(rep)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="localthings_SERVICES-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
# async_raw_write_sequence kicks a refresh after the write; a real
|
||||
# refresh would try to poll a session that doesn't exist for this unit
|
||||
# test, so replace it with a no-op the same way
|
||||
# test_coordinator_raw_write.py does.
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
|
||||
async_setup_services(hass)
|
||||
return coord
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_id(hass: HomeAssistant, coordinator: LocalThingsCoordinator) -> str:
|
||||
"""Register MAIN's HA device under the same identifiers a real config
|
||||
entry setup would have used (coordinator.device_info)."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info["identifiers"],
|
||||
)
|
||||
return device.id
|
||||
|
||||
|
||||
async def _call_write(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def _call_read(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# write_resource: ordered multi-write sequencing
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_posts_in_order_with_exact_bodies(hass, coordinator, device_id):
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/mode/vs/0", "payload": {"a": 1}},
|
||||
{"href": "/washer/vs/0", "payload": {"b": 2}},
|
||||
{"href": "/mode/vs/0", "payload": {"a": 3}},
|
||||
]
|
||||
response = await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [path for path, _ in fake.post_calls] == [
|
||||
["mode", "vs", "0"],
|
||||
["washer", "vs", "0"],
|
||||
["mode", "vs", "0"],
|
||||
]
|
||||
assert [cbor2.loads(body) for _, body in fake.post_calls] == [
|
||||
{"a": 1},
|
||||
{"b": 2},
|
||||
{"a": 3},
|
||||
]
|
||||
assert response is not None
|
||||
assert response["device_id"] == device_id
|
||||
assert len(response["results"]) == 3
|
||||
for result, write in zip(response["results"], writes, strict=True):
|
||||
assert result["href"] == write["href"]
|
||||
assert result["actual_href"] == write["href"] # MAIN -> identity transform
|
||||
assert result["raw_code"] == 0x44
|
||||
assert result["code"] == "2.04"
|
||||
assert result["accepted"] is True
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_write_resource_settle_honored_between_writes(hass, coordinator, device_id):
|
||||
"""settle waits *before the next write*, not after the last one --
|
||||
asserted on the recorded durations rather than a real sleep."""
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}, "settle": 5},
|
||||
{"href": "/c/vs/0", "payload": {"x": 3}, "settle": 9},
|
||||
]
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [call.args[0] for call in mock_sleep.call_args_list] == [2.0, 5.0]
|
||||
|
||||
|
||||
async def test_write_resource_changed_true_when_readback_matches_payload(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
|
||||
|
||||
async def test_write_resource_changed_false_when_readback_differs(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "unchanged"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# verify_after: the delayed re-read this feature exists for (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_held(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # write's own follow-up read
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # verify_after re-read: held
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert 30.0 in [call.args[0] for call in mock_sleep.call_args_list]
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is True
|
||||
assert verified["rep"] == {"x.field": "target"}
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_reverted(hass, coordinator, device_id):
|
||||
"""Issue #300's own symptom: a Samsung wall oven board answers 2.04
|
||||
Changed to a settings write while idle and then silently reverts it
|
||||
once the follow-up read has already come back clean. verify_after's
|
||||
whole purpose is catching exactly this -- `changed` (immediate) and
|
||||
`held` (delayed) must be able to disagree."""
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # looks accepted right after the write
|
||||
fake.queue_get("mode/vs/0", {"x.field": "original"}) # reverted by the time verify_after fires
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is False
|
||||
assert verified["rep"] == {"x.field": "original"}
|
||||
|
||||
|
||||
async def test_write_resource_no_verified_key_when_verify_after_is_zero(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert "verified" not in response
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Device target resolution
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_zero_devices_rejected(hass, coordinator):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_two_devices_rejected(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
target={"device_id": [device_id, "some-other-unrelated-device-id"]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_unresolvable_device_rejected(hass, coordinator):
|
||||
"""The device exists in the registry but no loaded coordinator claims
|
||||
it -- e.g. a device belonging to a different integration entirely."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
other_entry = MockConfigEntry(domain="other_domain")
|
||||
other_entry.add_to_hass(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=other_entry.entry_id,
|
||||
identifiers={("other_domain", "unrelated")},
|
||||
)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device.id, writes=[{"href": "/a/vs/0", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Subdevice href translation (issue #177)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_translates_href_for_indexed_subdevice(hass, coordinator):
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device.id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert fake.post_calls[0][0] == ["mode", "vs", "1"]
|
||||
result = response["results"][0]
|
||||
assert result["href"] == "/mode/vs/0"
|
||||
assert result["actual_href"] == "/mode/vs/1"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validation caps
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_rejects_more_than_ten_writes(hass, coordinator, device_id):
|
||||
writes = [{"href": f"/x/vs/{i}", "payload": {"a": i}} for i in range(11)]
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_settle_above_thirty_seconds(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 31}],
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "/a/vs/0", "payload": {}}])
|
||||
|
||||
|
||||
async def test_write_resource_rejects_non_dict_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass, device_id, writes=[{"href": "/a/vs/0", "payload": ["not", "a", "dict"]}]
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_href(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# read_resource
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_read_resource_with_href_does_live_get(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "live"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
assert fake.get_calls == [["mode", "vs", "0"]]
|
||||
assert response["href"] == "/mode/vs/0"
|
||||
assert response["actual_href"] == "/mode/vs/0"
|
||||
assert response["rep"] == {"x.field": "live"}
|
||||
|
||||
|
||||
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
# Seed the cache the way a poll would -- the fake session's `get` is
|
||||
# never touched by this test.
|
||||
coordinator._observe.apply("/mode/vs/0", {"x.field": "cached"}, source="poll")
|
||||
|
||||
response = await _call_read(hass, device_id)
|
||||
|
||||
assert fake.get_calls == []
|
||||
assert response["resources"]["/mode/vs/0"] == {"x.field": "cached"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Options-flow debug panel, rewired onto write_resource (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_goes_through_write_resource_service(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""Drives LocalThingsOptionsFlow's real step methods directly (rather
|
||||
than through hass.config_entries.options.async_init/async_configure,
|
||||
which needs the integration resolvable through HA's loader -- not
|
||||
exercised by any test in this repo and orthogonal to what this test is
|
||||
actually checking): the panel's async_step_debug_edit must call the
|
||||
write_resource service and get back a result the rest of the flow can
|
||||
render, not coord.async_raw_write directly."""
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
fake.queue_get("course/vs/0", {"x.field": "after"})
|
||||
coordinator._session = fake
|
||||
|
||||
flow = LocalThingsOptionsFlow()
|
||||
flow.hass = hass
|
||||
flow.handler = coordinator._entry.entry_id
|
||||
|
||||
write_result = await flow.async_step_debug_write({"href": "/course/vs/0"})
|
||||
assert write_result["step_id"] == "debug_edit"
|
||||
|
||||
edit_result = await flow.async_step_debug_edit({"payload": {"x.com.samsung.da.field": "value"}})
|
||||
assert edit_result["step_id"] == "debug_result"
|
||||
placeholders = edit_result["description_placeholders"]
|
||||
assert placeholders is not None
|
||||
assert placeholders["code"] == "2.04 (0x44)"
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
Reference in new issue
Block a user