From 9f82960c2c0a6f2e718d5a4422582a24be4c40e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:40:52 +0300 Subject: [PATCH] fix(import): remap vacuum routes to copied space (#611) Repair both incoming routes and safe target-reference recovery, with backend and mutation coverage for foreign and same-instance imports. Issue: #611 User-Visible: yes --- custom_components/houseplan/import_export.py | 26 ++++ docs/CHANGELOG.md | 4 + docs/CHANGELOG.ru.md | 4 + scripts/mutation-registry.mjs | 24 +++ tests_backend/test_ha_import_export.py | 150 +++++++++++++++++++ 5 files changed, 208 insertions(+) diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index 1e31e8c5..518645b0 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -1177,6 +1177,17 @@ def _repair_target_space_refs( preserve_related( marker_id, "marker.vacuum.segment_map", room_id, resolve_room, ) + routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None + if isinstance(routes, list): + for route in routes: + if not isinstance(route, dict): + continue + mapped = replace( + marker_id, "marker.vacuum.map_routes.space", + route.get("space"), resolve_space, + ) + if mapped is not None: + route["space"] = mapped controls = marker.get("controls") if isinstance(controls, list): for index, ref in enumerate(controls): @@ -1464,6 +1475,21 @@ def build_space_merge( new_id, old_room_id, ) vacuum["segment_map"] = remapped_segments + # A single-space export can retain only routes owned by that space. + # The copy itself gets a fresh id, so the retained route must follow + # it just like marker.space does; otherwise foreign imports fail the + # referential validator and same-instance imports silently target the + # source floor (#611). + if isinstance(vacuum, dict) and isinstance(vacuum.get("map_routes"), list): + for route in vacuum["map_routes"]: + if not isinstance(route, dict) \ + or str(route.get("space")) != old_space_id: + continue + route["space"] = new_space_id + _report_remap( + reference_report, "incoming", "marker.vacuum.map_routes.space", + new_id, old_space_id, + ) if binding in duplicate and duplicate_policy == "virtual": virtualized += 1 virtualized_targets.add(old_id) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 490a8add..90ddb7f1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Space imports now keep robot map routes on the imported copy: transfers to + another Home Assistant instance no longer fail on the old floor ID, and + same-instance copies no longer leave the robot trail on the source floor + ([#611](https://github.com/Matysh/houseplan-card/issues/611)). - Settings forms now keep the same reviewed shell inside Home Assistant as in the standalone preview: a 560 px desktop canvas with one scrollbar and a height cap, plus edge-to-edge fullscreen layout on phones. Other dialogs are diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 74ea9033..6286e7cb 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- При импорте пространства маршруты карт робота теперь следуют за созданной + копией: перенос в другую установку Home Assistant больше не падает из-за ID + старого этажа, а копия в той же установке не оставляет трек робота на + исходном этаже ([#611](https://github.com/Matysh/houseplan-card/issues/611)). - Формы настроек теперь выглядят внутри Home Assistant так же, как в принятом макете: канва шириной 560 px на компьютере с одной прокруткой и ограничением высоты, а на телефоне — полноэкранный диалог без отступов. Остальные диалоги diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index f0303b92..5cac2a70 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5620,6 +5620,30 @@ const MUTANT_DEFINITIONS = [ replace: '**({"plan_only": plan_only}),', }], }, + { + id: 'space-import-keeps-incoming-vacuum-route-space', + guard: 'node scripts/backend-test-guard.mjs issue_611_incoming_route_follows_fresh_space', + because: 'a copied vacuum route must follow the fresh space id on both foreign and ' + + 'same-instance imports instead of failing validation or silently targeting the source floor', + patches: [{ + file: 'custom_components/houseplan/import_export.py', + find: ' route["space"] = new_space_id\n' + + ' _report_remap(', + replace: ' route["space"] = old_space_id # mutant: keep source floor\n' + + ' _report_remap(', + }], + }, + { + id: 'space-import-keeps-target-vacuum-route-space', + guard: 'node scripts/backend-test-guard.mjs issue_611_target_route_follows_safe_space_repair', + because: 'the second-pass target repair must apply its proven exact/lineage space mapping to ' + + 'vacuum routes as well as marker.space', + patches: [{ + file: 'custom_components/houseplan/import_export.py', + find: ' route["space"] = mapped\n', + replace: ' route["space"] = route.get("space") # mutant: ignore mapping\n', + }], + }, { id: 'plan-only-revalidate-flag-dropped', guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects', diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index cc74a45e..953c2092 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -1504,6 +1504,156 @@ def test_space_remap_covers_marker_id_layout_and_vacuum_segment_map(tmp_path: Pa assert marker["id"] in layout and "lamp" not in layout +def _issue_611_space_document(tmp_path: Path) -> dict[str, Any]: + config = _config() + config["markers"][0]["vacuum"] = { + "source": "camera.robot", + "map_routes": [{ + "id": "route-ground", "source": "camera.robot", "map_id": "ground-map", + "space": "ground", "calibration": [1, 0, 0, 0, 1, 0], + }], + } + document, _filename = create_export( + SimpleNamespace(instance_id="instance-a"), + {"config": config, "rev": 2}, + {"layout": {"lamp": {"x": 0.4, "y": 0.5, "s": "ground"}}, "rev": 3}, + kind="space", space_id="ground", card_version="review", config_root=tmp_path, + ) + return parse_document(json.dumps(document).encode()) + + +@pytest.mark.parametrize("same_source", [False, True], ids=["foreign", "same-instance"]) +def test_issue_611_incoming_route_follows_fresh_space( + tmp_path: Path, same_source: bool, +) -> None: + document = _issue_611_space_document(tmp_path) + current = _config() if same_source else {"spaces": [], "markers": [], "settings": {}} + if same_source: + current["markers"] = [] + + merged, _layout, details = build_space_merge( + document, current, {}, "skip", same_source=same_source, + ) + marker = next( + item for item in merged["markers"] + if item.get("space") == details["space_id"] + ) + + assert marker["space"] != "ground" + assert marker["vacuum"]["map_routes"][0]["space"] == details["space_id"] + assert details["reference_report"]["remapped"]["incoming"] == { + "marker.space": 1, + "marker.room_id": 1, + "marker.vacuum.map_routes.space": 1, + "layout.owner": 1, + "layout.space": 1, + } + + +@pytest.mark.parametrize( + "dead_space", ["ground", "space_ground_aaaaaaaa"], ids=["exact", "lineage"], +) +def test_issue_611_target_route_follows_safe_space_repair( + tmp_path: Path, dead_space: str, +) -> None: + document = _document(tmp_path, "space") + current = { + "spaces": [], + "markers": [{ + "id": "target-vac", "binding": "virtual", "space": dead_space, + "vacuum": { + "source": "camera.robot", + "map_routes": [{ + "id": "route-ground", "source": "camera.robot", + "map_id": "ground-map", "space": dead_space, + "calibration": [1, 0, 0, 0, 1, 0], + }], + }, + }], + "settings": {}, + } + + merged, _layout, details = build_space_merge(document, current, {}, "skip") + marker = next(item for item in merged["markers"] if item["id"] == "target-vac") + + assert marker["space"] == details["space_id"] + assert marker["vacuum"]["map_routes"][0]["space"] == details["space_id"] + assert details["repaired_target_refs"] == 2 + assert details["reference_report"]["remapped"]["target"] == { + "marker.space": 1, + "marker.vacuum.map_routes.space": 1, + } + + +def test_issue_611_live_target_route_is_not_rebound(tmp_path: Path) -> None: + document = _document(tmp_path, "space") + current = _config() + current["markers"][0]["vacuum"] = { + "source": "camera.robot", + "map_routes": [{ + "id": "route-ground", "source": "camera.robot", "map_id": "ground-map", + "space": "ground", "calibration": [1, 0, 0, 0, 1, 0], + }], + } + + merged, _layout, details = build_space_merge(document, current, {}, "skip") + marker = next(item for item in merged["markers"] if item["id"] == "lamp") + + assert marker["space"] == "ground" + assert marker["vacuum"]["map_routes"][0]["space"] == "ground" + assert details["repaired_target_refs"] == 0 + assert "marker.vacuum.map_routes.space" not in \ + details["reference_report"]["remapped"]["target"] + + +def test_issue_611_ambiguous_target_route_is_preserved_and_reported() -> None: + current = { + "spaces": [{ + "id": "space_ground_aaaaaaaa", "title": "Existing", + "view_box": [0, 0, 1, 1], "rooms": [], + }], + "markers": [{ + "id": "target-vac", "binding": "virtual", "space": "space_ground_aaaaaaaa", + "vacuum": {"map_routes": [{"space": "space_ground_bbbbbbbb"}]}, + }], + "settings": {}, + } + report = import_export_api._empty_reference_report() + + repaired, _layout, count = import_export_api._repair_target_space_refs( + current, {}, {"space": {"ground": "space_ground_cccccccc"}}, {}, report, set(), + ) + + assert repaired["markers"][0]["vacuum"]["map_routes"][0]["space"] == \ + "space_ground_bbbbbbbb" + assert count == 0 + assert report["preservedUnresolved"] == { + "marker.vacuum.map_routes.space": 1, + } + + +@pytest.mark.parametrize( + "vacuum", + [ + {"source": "camera.robot", "map_routes": []}, + {"source": "camera.robot", "calibration": {"ground-map": [1, 0, 0, 0, 1, 0]}}, + ], + ids=["empty-explicit-routes", "legacy-calibration"], +) +def test_issue_611_non_route_vacuum_shapes_stay_unchanged( + tmp_path: Path, vacuum: dict[str, Any], +) -> None: + document = _document(tmp_path, "space") + document["payload"]["config"]["markers"][0]["vacuum"] = copy.deepcopy(vacuum) + + merged, _layout, details = build_space_merge( + document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip", + ) + marker = next(item for item in merged["markers"] if item["space"] == details["space_id"]) + + assert marker["vacuum"] == vacuum + + def test_issue_244_space_import_repairs_existing_target_refs_with_exact_map(tmp_path: Path) -> None: document = _document(tmp_path, "space") current = {