From 2946e28352f35962f617d1066e53f3b8e9a4e044 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 08:34:46 +0300 Subject: [PATCH] fix: import result follows the commit; dropped route runs reach the store (#495) Apply re-validated the preview token after both halves were durable, so a TTL that lapsed during the write, or an eviction by a newer preview of the same user, answered "preview expired" for a plan that was already replaced and withheld both update events. The token is now simply spent after the commit; validity is decided once, on entry under write_lock. Route runs dropped by drop_unknown_routes never reached the store unless a marker happened to be orphaned in the same pass; an idle robot kept them in memory only and a restart brought them back. The drop now happens under _refresh_lock, leaves with the orphan transaction or with an immediate write of its own, and rolls back like #335 when the store refuses. Tests: HA harness for both apply races and a live config/set route drop, recorder stubs for the four durability cases; five mutants caught by the standard runner. Issue: #495 User-Visible: yes --- custom_components/houseplan/trails.py | 153 +++++++++++++------ custom_components/houseplan/websocket_api.py | 8 +- docs/CHANGELOG.md | 8 + docs/CHANGELOG.ru.md | 8 + scripts/mutation-gate.mjs | 69 +++++++++ tests_backend/test_ha_import_export.py | 70 +++++++++ tests_backend/test_ha_websocket.py | 49 ++++++ tests_backend/test_trail_recorder.py | 112 ++++++++++++++ 8 files changed, 427 insertions(+), 50 deletions(-) diff --git a/custom_components/houseplan/trails.py b/custom_components/houseplan/trails.py index 5c009aa6..3f6e0bad 100755 --- a/custom_components/houseplan/trails.py +++ b/custom_components/houseplan/trails.py @@ -317,15 +317,53 @@ class TrailRecorder: A tombstone deliberately stays in config so discovery cannot resurrect a deleted device. For live tracking and trail ownership it is absent: this is the same boundary used by ``async_refresh`` above. + + Returns the number of erased markers. Runs dropped because their route + vanished (#162) are not counted, but they are just as durable (#495): + they leave with the orphan transaction, or with a write of their own. """ live_marker_ids = { str(marker.get("id")) for marker in config.get("markers") or [] if marker.get("id") is not None and marker.get("removed") is not True } - # #162: a route that vanished (deleted, or re-targeted to another - # space, which is a new identity) takes its own runs with it, while the - # marker and its other routes stay untouched. + fire = False + try: + async with self._refresh_lock: + # #162: a route that vanished (deleted, or re-targeted to + # another space, which is a new identity) takes its own runs + # with it, while the marker and its other routes stay untouched. + dropped = self._drop_unknown_routes(config) + orphan_ids = set(self.book.data) - live_marker_ids + if not orphan_ids and not dropped: + return 0 + try: + if orphan_ids: + removed = await self._delete_many_locked(orphan_ids) + else: + removed = 0 + await self._save_now_locked() + except Exception: + # The store is the durable authority (#335): a drop that + # never reached it must not survive only in memory, or a + # restart brings the runs back while the card believed + # them gone. Put them back so the next commit retries. + self._restore_dropped(dropped) + raise + fire = True + return removed + except Exception: # noqa: BLE001 — config commit already succeeded + _LOGGER.exception( + "House Plan: reconciling vacuum trails after a config commit failed", + ) + return 0 + finally: + if fire: + self.hass.bus.async_fire("houseplan_trail_updated", {}) + + def _drop_unknown_routes(self, config: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Forget runs of routes the config no longer has; return what was dropped.""" + dropped: dict[str, dict[str, Any]] = {} for marker in config.get("markers") or []: marker_id = str(marker.get("id")) if marker.get("removed") is True or marker_id not in self.book.data: @@ -333,56 +371,75 @@ class TrailRecorder: vacuum = marker.get("vacuum") or {} routes = effective_routes( marker_id, vacuum, str(marker.get("space") or ""), vacuum.get("source")) - self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes}) - orphan_ids = set(self.book.data) - live_marker_ids - if not orphan_ids: - return 0 + before = { + slot: run for slot, run in (self.book.data.get(marker_id) or {}).items() + if slot in ("current", "previous") + } + if self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes}): + after = self.book.data.get(marker_id) or {} + dropped[marker_id] = { + slot: run for slot, run in before.items() if slot not in after + } + return dropped + + def _restore_dropped(self, dropped: dict[str, dict[str, Any]]) -> None: + for marker_id, runs in dropped.items(): + self.book.data.setdefault(marker_id, {}).update(runs) + + async def _save_now_locked(self) -> None: + """Write the book immediately, superseding a pending debounced write. + + A failed write puts the debounce back: the points it was going to + persist are still only in memory. + """ + had_pending_save = self._unsub_save is not None + if self._unsub_save: + self._unsub_save() + self._unsub_save = None try: - return await self._async_delete_many(orphan_ids) - except Exception: # noqa: BLE001 — config commit already succeeded - _LOGGER.exception( - "House Plan: removing orphan vacuum trails failed: markers=%s", - sorted(orphan_ids), - ) - return 0 + await self.store.async_save(self.book.data) + except Exception: + if had_pending_save: + self._schedule_save() + raise async def _async_delete_many(self, markers: set[str]) -> int: """Delete one or more books with one subscription/store transaction.""" async with self._refresh_lock: - # The trail book owns deletion. When it has no such marker, this - # is a no-op and must not silently damage the live tracking graph. - removed = { - marker: self.book.data.pop(marker) - for marker in markers - if marker in self.book.data - } - if not removed: - return 0 - previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()} - had_pending_save = self._unsub_save is not None - try: - for src in list(self.pairs): - kept = [pair for pair in self.pairs[src] if pair[0] not in removed] - if kept: - self.pairs[src] = kept - else: - del self.pairs[src] - self._resubscribe() - if self._unsub_save: - self._unsub_save() - self._unsub_save = None - await self.store.async_save(self.book.data) - except Exception: - # The store is the durable authority. Restore the in-memory - # owner graph so the next successful config sync can retry - # instead of leaving an orphan on disk forever (#335). - self.book.data.update(removed) - self.pairs = previous_pairs - self._resubscribe() - if had_pending_save: - self._schedule_save() - raise - self.hass.bus.async_fire("houseplan_trail_updated", {}) + removed = await self._delete_many_locked(markers) + if removed: + self.hass.bus.async_fire("houseplan_trail_updated", {}) + return removed + + async def _delete_many_locked(self, markers: set[str]) -> int: + """The transaction of ``_async_delete_many``; the caller holds the lock.""" + # The trail book owns deletion. When it has no such marker, this + # is a no-op and must not silently damage the live tracking graph. + removed = { + marker: self.book.data.pop(marker) + for marker in markers + if marker in self.book.data + } + if not removed: + return 0 + previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()} + try: + for src in list(self.pairs): + kept = [pair for pair in self.pairs[src] if pair[0] not in removed] + if kept: + self.pairs[src] = kept + else: + del self.pairs[src] + self._resubscribe() + await self._save_now_locked() + except Exception: + # The store is the durable authority. Restore the in-memory + # owner graph so the next successful config sync can retry + # instead of leaving an orphan on disk forever (#335). + self.book.data.update(removed) + self.pairs = previous_pairs + self._resubscribe() + raise return len(removed) def _resubscribe(self) -> None: diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index c5ca2118..81e39699 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -630,8 +630,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) "final_metadata": original_metadata, } await _commit_pair(rt, pending, rollback) - # A token becomes single-use only after both durable halves land. - get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True) + # Both halves are durable: the token is spent no matter what + # happened to the preview registry meanwhile. Re-validating it + # here (TTL lapsed during the write, eviction by a newer preview + # of the same user) would report a failure for a plan that is + # already replaced — the outcome follows the commit (#495). + rt.import_previews.pop(msg["token"], None) except ImportFailure as err: _send_import_error(connection, msg["id"], err) return diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0b14c78..0381fc70 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Applying a backup import no longer reports "preview expired" after the plan + was already replaced: once both halves of the import are written, the result + and the update events follow the commit even if the preview timed out or was + displaced by a newer preview while the write was in flight. Deleting or + re-targeting a robot map route now removes its recorded runs from disk as + well as from memory, so a Home Assistant restart cannot bring them back + ([#495](https://github.com/Matysh/houseplan-card/issues/495)). + ## v1.73.0-beta.7 — 2026-09-09 - Summary-panel settings stay responsive with large Home Assistant installs: diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index b3d40af1..6b00f32e 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,14 @@ ## Не выпущено +- Применение импорта из резервной копии больше не отвечает «preview expired» + после того, как план уже заменён: когда обе половины импорта записаны, ответ + и события обновления следуют за записью, даже если срок предпросмотра истёк + или его вытеснил более новый предпросмотр во время записи. Удаление или + перенацеливание маршрута карты робота теперь убирает записанные проезды и с + диска, а не только из памяти — перезапуск Home Assistant их не вернёт + ([#495](https://github.com/Matysh/houseplan-card/issues/495)). + ## v1.73.0-beta.7 — 2026-09-09 - Настройки сводной панели теперь остаются отзывчивыми даже при большом числе diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 51063ce6..0405fd14 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -7949,6 +7949,75 @@ const MUTANT_DEFINITIONS = [ replace: ' checked = _normalize(msg["config"]) # mutant: Optimize bypasses summary guard', }], }, + { + id: 'import-apply-rechecks-preview-after-commit', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_495_apply_result ' + + 'tests_backend/test_ha_import_export.py', + because: 'once both halves are durable, a lapsed TTL or an eviction by a newer preview must ' + + 'not turn the accepted import into an error (#495 AC1/AC2)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' rt.import_previews.pop(msg["token"], None)\n', + replace: ' get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)\n', + }], + }, + { + id: 'trail-purge-drops-routes-in-memory-only', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_495_dropped_route_runs_reach_the_store ' + + 'tests_backend/test_trail_recorder.py', + because: 'a route deleted from a live marker without any orphan must still write the book, or a ' + + 'restart brings its runs back (#495 AC3)', + patches: [{ + file: 'custom_components/houseplan/trails.py', + find: ' removed = 0\n await self._save_now_locked()\n', + replace: ' removed = 0 # mutant: memory only\n', + }], + }, + { + id: 'trail-purge-keeps-dropped-runs-after-failed-write', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_495_dropped_route_runs_roll_back ' + + 'tests_backend/test_trail_recorder.py', + because: 'when the store refuses the write the dropped runs must return to memory so the next ' + + 'commit retries instead of diverging from the disk (#495 AC4)', + patches: [{ + file: 'custom_components/houseplan/trails.py', + find: ' self._restore_dropped(dropped)\n raise\n', + replace: ' raise # mutant: memory keeps the drop the disk never saw\n', + }], + }, + { + id: 'trail-purge-forgets-routes-when-orphans-exist', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_495_dropped_route_runs_share_the_orphan_transaction ' + + 'tests_backend/test_trail_recorder.py', + because: 'orphan deletion and route drops are one transaction: the orphan path must not write a ' + + 'book that still holds the dropped runs (#495 AC5)', + patches: [{ + file: 'custom_components/houseplan/trails.py', + find: ' if orphan_ids:\n removed = await self._delete_many_locked(orphan_ids)\n', + replace: ' if orphan_ids:\n self._restore_dropped(dropped) # mutant\n' + + ' removed = await self._delete_many_locked(orphan_ids)\n', + }], + }, + { + id: 'trail-purge-failed-orphan-write-loses-dropped-runs', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_495_failed_orphan_transaction_restores_dropped_route_runs_too ' + + 'tests_backend/test_trail_recorder.py', + because: 'the #335 rollback of the orphan transaction must cover the route drops made in the same ' + + 'pass, not only the orphan books (#495 AC4)', + patches: [{ + file: 'custom_components/houseplan/trails.py', + find: ' except Exception:\n' + + ' # The store is the durable authority (#335): a drop that\n', + replace: ' except Exception:\n' + + ' dropped = {} # mutant: nothing to restore\n' + + ' # The store is the durable authority (#335): a drop that\n', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index 3a778703..0d28724e 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -2522,6 +2522,76 @@ async def test_success_events_are_emitted_only_after_both_target_writes( assert all(config_done and layout_done for _event, config_done, layout_done in observed) +async def _apply_with_registry_hook( + hass: HomeAssistant, tmp_path: Path, monkeypatch, hook, +) -> tuple[Any, dict[str, Any], _Connection, list[str]]: + """Apply a full import; ``hook(rt, token)`` runs after both halves are durable.""" + await _setup(hass) + rt, response, _ = await _candidate(hass, tmp_path) + real_commit = wsapi._commit_pair + + async def commit_then_hook(runtime: Any, pending: dict[str, Any], rollback: dict[str, Any]) -> None: + await real_commit(runtime, pending, rollback) + hook(runtime, response["token"]) + + fired: list[str] = [] + + def fire(_bus: Any, event_type: str, _event_data: dict[str, Any] | None = None, **_kwargs: Any) -> None: + if event_type in {"houseplan_config_updated", "houseplan_layout_updated"}: + fired.append(event_type) + + monkeypatch.setattr(wsapi, "_commit_pair", commit_then_hook) + monkeypatch.setattr(type(hass.bus), "async_fire", fire) + connection = await _apply(hass, response) + return rt, response, connection, fired + + +async def test_issue_495_apply_result_follows_the_commit_when_the_preview_expires_meanwhile( + hass: HomeAssistant, tmp_path: Path, monkeypatch, +) -> None: + """#495 AC1: a TTL that lapses during the write cannot turn a durable import into an error.""" + def expire(rt: Any, token: str) -> None: + rt.import_previews[token]["expires"] = 0 + + rt, response, connection, fired = await _apply_with_registry_hook(hass, tmp_path, monkeypatch, expire) + assert connection.error is None, connection.error + assert connection.result and connection.result["ok"] + assert connection.result["config_rev"] == connection.result["layout_rev"] == 2 + assert fired == ["houseplan_config_updated", "houseplan_layout_updated"] + assert response["token"] not in rt.import_previews + assert (await rt.config_store.async_load())["rev"] == 2 + assert (await rt.config_store.async_load())["config"]["spaces"][0]["title"] == "Imported" + + +async def test_issue_495_apply_result_survives_eviction_by_a_newer_preview( + hass: HomeAssistant, tmp_path: Path, monkeypatch, +) -> None: + """#495 AC2: previews made by the same user during the write may evict the token; the import stands.""" + monkeypatch.setattr(import_export_api, "MAX_IMPORT_PREVIEWS_PER_USER", 1) + newer: list[str] = [] + + def evict(rt: Any, token: str) -> None: + raw = json.dumps(create_export( + rt, {"config": _config(), "rev": 1}, {"layout": {}, "rev": 1}, + kind="full", space_id=None, card_version="1.61.0", + config_root=Path(hass.config.path("")), + )[0]).encode() + newer.append(create_preview( + rt, raw, owner_id="review-owner", duplicate_policy="skip", + current_config_data={"config": _config(), "rev": 1}, + current_layout_data={"layout": {}, "rev": 1}, config_root=Path(hass.config.path("")), + )["token"]) + assert token not in rt.import_previews, "the hook must evict the applying token" + + rt, response, connection, fired = await _apply_with_registry_hook(hass, tmp_path, monkeypatch, evict) + assert connection.error is None, connection.error + assert connection.result and connection.result["ok"] + assert fired == ["houseplan_config_updated", "houseplan_layout_updated"] + assert response["token"] not in rt.import_previews + assert newer and all(token in rt.import_previews for token in newer) + assert (await rt.store.async_load())["rev"] == 2 + + async def test_pair_rolls_back_before_reporting_a_persistent_target_failure( hass: HomeAssistant, tmp_path: Path, monkeypatch, ) -> None: diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index aa7d9498..5dc40825 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -474,6 +474,55 @@ async def test_config_set_purges_tombstoned_and_absent_trails_durably( assert "late_orphan" in (await recorder.store.async_load() or {}) +async def test_issue_495_config_set_dropping_a_route_purges_its_runs_durably( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """#495 AC6: a route deleted from a live marker takes its runs off the disk, not just out of memory.""" + await _setup(hass) + client = await hass_ws_client(hass) + + def robot(*route_ids: str) -> dict: + return { + "id": "robot", "binding": "entity:vacuum.robot", "space": "f1", + "vacuum": {"source": "camera.map", "map_routes": [ + {"id": route_id, "source": "camera.map", "map_id": route_id, + "space": "f1", "calibration": [1, 0, 0, 0, 1, 0]} + for route_id in route_ids + ]}, + } + + initial = {"spaces": [_space("f1", "r1")], "markers": [robot("vr_old", "vr_keep")], "settings": {}} + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": initial, "expected_rev": 0, + }) + first = await client.receive_json() + assert first["success"], first + await hass.async_block_till_done() + + recorder = hass.data[DOMAIN]["trail_recorder"] + recorder.book.data = {"robot": { + "current": {"route_id": "vr_old", "map_id": "vr_old", "points": [[1, 2]]}, + "previous": {"route_id": "vr_keep", "map_id": "vr_keep", "points": [[3, 4]]}, + }} + await recorder.store.async_save(copy.deepcopy(recorder.book.data)) + + candidate = {**copy.deepcopy(initial), "markers": [robot("vr_keep")]} + await client.send_json_auto_id({ + "type": "houseplan/config/set", "config": candidate, + "expected_rev": first["result"]["rev"], + }) + dropped = await client.receive_json() + assert dropped["success"], dropped + + await client.send_json_auto_id({"type": "houseplan/trail/get"}) + trails = (await client.receive_json())["result"]["trails"] + assert trails["robot"].get("current") is None + assert trails["robot"]["previous"]["route_id"] == "vr_keep" + durable = await recorder.store.async_load() or {} + assert durable["robot"].get("current") is None, "the dropped run must not survive a restart" + assert durable["robot"]["previous"]["route_id"] == "vr_keep" + + async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: await _setup(hass) client = await hass_ws_client(hass) diff --git a/tests_backend/test_trail_recorder.py b/tests_backend/test_trail_recorder.py index 55354423..16bf602f 100644 --- a/tests_backend/test_trail_recorder.py +++ b/tests_backend/test_trail_recorder.py @@ -294,6 +294,118 @@ def test_failed_orphan_store_write_rolls_back_and_can_be_retried(): assert hass.bus.fired == [("houseplan_trail_updated", {})] +def _route_book(): + """One live marker: a run filed under a route that is about to vanish, one to keep.""" + return { + "live": { + "current": {"route_id": "vr_old", "map_id": "1", "points": [[1, 2]]}, + "previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}, + }, + } + + +def _live_marker_without_vr_old(): + return { + "id": "live", "space": "ground", + "vacuum": {"map_routes": [ + {"id": "vr_keep", "source": "camera.map", "map_id": "1", "space": "ground"}, + ]}, + } + + +class _RecordingStore: + def __init__(self): + self.saved = [] + + async def async_save(self, data): + self.saved.append(json.loads(json.dumps(data))) + + +def test_issue_495_dropped_route_runs_reach_the_store_without_orphans(): + """#495 AC3: deleting a route persists the drop even when no marker is orphaned.""" + rec, hass, _states = _rec() + rec.book.data = _route_book() + rec.pairs = {"camera.map": [("live", "vacuum.x50")]} + rec.store = _RecordingStore() + pending_cancelled = [] + rec._unsub_save = lambda: pending_cancelled.append(True) + + removed = _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) + + assert removed == 0, "the return value still counts markers, not runs" + assert len(rec.store.saved) == 1 + assert rec.store.saved[0] == { + "live": {"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}}, + } + assert pending_cancelled == [True] and rec._unsub_save is None + assert hass.bus.fired == [("houseplan_trail_updated", {})] + # Restart model: a fresh book from what the store holds has no vr_old run. + restarted = trails.TrailBook(json.loads(json.dumps(rec.store.saved[-1]))) + assert restarted.data["live"].get("current") is None + assert restarted.data["live"]["previous"]["route_id"] == "vr_keep" + # No change → no write, no event. + _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) + assert len(rec.store.saved) == 1 + assert hass.bus.fired == [("houseplan_trail_updated", {})] + + +def test_issue_495_dropped_route_runs_roll_back_when_the_store_write_fails(): + """#495 AC4: memory follows the store — a failed write keeps the run for a retry.""" + rec, hass, _states = _rec() + rec.book.data = _route_book() + rec.pairs = {"camera.map": [("live", "vacuum.x50")]} + + class FailingStore: + async def async_save(self, _data): + raise OSError("disk full") + + rec.store = FailingStore() + assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0 + assert rec.book.data == _route_book() + assert hass.bus.fired == [] + + rec.store = _RecordingStore() + assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0 + assert rec.store.saved[-1]["live"] == { + "previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}, + } + assert hass.bus.fired == [("houseplan_trail_updated", {})] + + +def test_issue_495_dropped_route_runs_share_the_orphan_transaction(): + """#495 AC5: orphans and dropped routes leave in one write with one event.""" + rec, hass, _states = _rec() + rec.book.data = {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}} + rec.pairs = {"camera.map": [("live", "vacuum.x50"), ("orphan", "vacuum.x50")]} + rec.store = _RecordingStore() + + removed = _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) + + assert removed == 1 + assert len(rec.store.saved) == 1 + assert rec.store.saved[0] == { + "live": {"previous": {"route_id": "vr_keep", "map_id": "1", "points": [[3, 4]]}}, + } + assert rec.pairs == {"camera.map": [("live", "vacuum.x50")]} + assert hass.bus.fired == [("houseplan_trail_updated", {})] + + +def test_issue_495_failed_orphan_transaction_restores_dropped_route_runs_too(): + """#495 AC4 in the orphan path: rollback covers both halves of the write.""" + rec, hass, _states = _rec() + rec.book.data = {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}} + rec.pairs = {"camera.map": [("live", "vacuum.x50"), ("orphan", "vacuum.x50")]} + + class FailingStore: + async def async_save(self, _data): + raise OSError("disk full") + + rec.store = FailingStore() + assert _run_isolated(rec.async_purge_orphans({"markers": [_live_marker_without_vr_old()]})) == 0 + assert rec.book.data == {**_route_book(), "orphan": {"current": {"points": [[5, 6]]}}} + assert hass.bus.fired == [] + + def test_object_style_position_is_read(): # Tasshack in-memory attributes hold a Point OBJECT, not a dict class Point: