v1.60.0: harden background editing and device state

This commit is contained in:
Matysh
2026-08-07 13:02:46 +03:00
parent 29fb9deb43
commit 3028122016
47 changed files with 2566 additions and 674 deletions
+33 -2
View File
@@ -59,6 +59,7 @@ async def test_deleted_marker_rejects_a_stale_layout_update(
"markers": [
{"id": "dev1", "binding": "device:dev1", "removed": True},
{"id": "v_live", "binding": "virtual", "name": "Still here"},
{"id": "v_real", "binding": "device:real-device"},
],
"settings": {},
}
@@ -84,8 +85,20 @@ async def test_deleted_marker_rejects_a_stale_layout_update(
assert ignored_virtual["success"]
assert ignored_virtual["result"]["ignored"] == "missing_virtual"
# `v_` is only the historical virtual naming convention. An explicit
# non-virtual marker with that prefix must remain positionable.
await client.send_json_auto_id({
"type": "houseplan/layout/update",
"device_id": "v_real",
"pos": {"s": "f1", "x": 0.2, "y": 0.25},
})
real = await client.receive_json()
assert real["success"] and "ignored" not in real["result"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"] == {}
assert (await client.receive_json())["result"]["layout"] == {
"v_real": {"s": "f1", "x": 0.2, "y": 0.25},
}
# The compatibility wholesale endpoint applies the same filter.
await client.send_json_auto_id({
@@ -94,14 +107,18 @@ async def test_deleted_marker_rejects_a_stale_layout_update(
"dev1": {"s": "f1", "x": 0.1, "y": 0.2},
"v_deleted": {"s": "f1", "x": 0.3, "y": 0.4},
"v_live": {"s": "f1", "x": 0.4, "y": 0.5},
"v_real": {"s": "f1", "x": 0.45, "y": 0.55},
"rl_r1": {"s": "f1", "x": 0.35, "y": 0.45},
"ordinary_auto_device": {"s": "f1", "x": 0.5, "y": 0.6},
},
"expected_rev": 0,
"expected_rev": 1,
})
assert (await client.receive_json())["success"]
await client.send_json_auto_id({"type": "houseplan/layout/get"})
assert (await client.receive_json())["result"]["layout"] == {
"v_live": {"s": "f1", "x": 0.4, "y": 0.5},
"v_real": {"s": "f1", "x": 0.45, "y": 0.55},
"rl_r1": {"s": "f1", "x": 0.35, "y": 0.45},
"ordinary_auto_device": {"s": "f1", "x": 0.5, "y": 0.6},
}
@@ -119,6 +136,20 @@ async def test_trail_delete_clears_the_server_book(
assert "m1" not in recorder.book.data
async def test_trail_delete_rejects_non_admin_without_mutation(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
await _setup(hass)
recorder = hass.data[DOMAIN]["trail_recorder"]
recorder.book.data["m1"] = {"current": {"points": [[1, 2]]}}
user = await hass.auth.async_create_user("ordinary")
client = await hass_ws_client(hass, user=user)
await client.send_json_auto_id({"type": "houseplan/trail/delete", "marker_id": "m1"})
response = await client.receive_json()
assert not response["success"] and response["error"]["code"] == "unauthorized"
assert "m1" in recorder.book.data
async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
+30
View File
@@ -143,6 +143,36 @@ def test_trail_book_delete_forgets_current_and_previous_runs():
assert book.delete("m1") is False
def test_missing_trail_delete_does_not_mutate_live_tracking_pairs():
rec, _hass, _states = _rec()
before = {src: list(pairs) for src, pairs in rec.pairs.items()}
unsubscribed = []
rec._unsub_track = lambda: unsubscribed.append(True)
assert _run_isolated(rec.async_delete("m1")) is False
assert rec.pairs == before
assert unsubscribed == []
def test_trail_delete_prunes_pair_and_replaces_subscription():
tracked = []
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = lambda hass, ents, cb: (
tracked.append(list(ents)) or (lambda: None)
)
try:
rec, _hass, _states = _rec()
rec.pairs["other.source"] = [("m2", "vacuum.other")]
rec.book.data["m1"] = {"current": {"points": [[1, 2]]}}
unsubscribed = []
rec._unsub_track = lambda: unsubscribed.append(True)
assert _run_isolated(rec.async_delete("m1")) is True
assert rec.pairs == {"other.source": [("m2", "vacuum.other")]}
assert unsubscribed == [True]
assert tracked == [["other.source", "vacuum.other"]]
finally:
trails.async_track_state_change_event = old_track
def test_object_style_position_is_read():
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
class Point:
+9
View File
@@ -136,6 +136,15 @@ def test_space_backdrop_transform_is_optional_and_bounded():
v.SPACE_SCHEMA({**ok, **bad})
def test_space_cell_scale_is_optional_finite_and_bounded():
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
assert "cell_cm" not in v.SPACE_SCHEMA(ok)
assert v.SPACE_SCHEMA({**ok, "cell_cm": 20})["cell_cm"] == 20.0
for bad in (0, -1, v.CELL_CM_MAX + 1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "cell_cm": bad})
def test_marker_schema():
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",