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
This commit is contained in:
Claude
2026-09-23 04:40:52 +03:00
parent 2a21ff65d4
commit 9f82960c2c
5 changed files with 208 additions and 0 deletions
@@ -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)
+4
View File
@@ -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
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- При импорте пространства маршруты карт робота теперь следуют за созданной
копией: перенос в другую установку Home Assistant больше не падает из-за ID
старого этажа, а копия в той же установке не оставляет трек робота на
исходном этаже ([#611](https://github.com/Matysh/houseplan-card/issues/611)).
- Формы настроек теперь выглядят внутри Home Assistant так же, как в принятом
макете: канва шириной 560 px на компьютере с одной прокруткой и ограничением
высоты, а на телефоне — полноэкранный диалог без отступов. Остальные диалоги
+24
View File
@@ -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',
+150
View File
@@ -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 = {