mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- При импорте пространства маршруты карт робота теперь следуют за созданной
|
||||
копией: перенос в другую установку Home Assistant больше не падает из-за ID
|
||||
старого этажа, а копия в той же установке не оставляет трек робота на
|
||||
исходном этаже ([#611](https://github.com/Matysh/houseplan-card/issues/611)).
|
||||
- Формы настроек теперь выглядят внутри Home Assistant так же, как в принятом
|
||||
макете: канва шириной 560 px на компьютере с одной прокруткой и ограничением
|
||||
высоты, а на телефоне — полноэкранный диалог без отступов. Остальные диалоги
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user