feat(graph): duplicate a selected subgraph server-side
A secret-safe equivalent of blueprint import: the graph editor's overflow menu gains "Duplicate selection", which deep-clones the selected value and colour-strip sources server-side (full config preserved, never crossing the wire) and rewires references that point within the selection — shared dependencies (devices, HA sources, …) stay shared. - graph_schema.remap_refs: write-twin of extract_refs (same dot/list/bindable grammar) that rewrites only in-selection ids; 8 unit tests. - BaseSqliteStore.clone(): faithful deep-copy clone (no schema round-trip, so no field is lost), prefix-preserving fresh id; reusable by any store. - POST /api/v1/graph/duplicate: two-pass clone-then-rewire restricted to value / colour-strip sources (no inline secrets), with a safety net flagging any unremapped reference; 7 integration tests vs real stores. - Frontend: duplicateSubgraph (+cache invalidation), graphDuplicateSelection (reload + reselect the new cluster), overflow-menu item, i18n (en/ru/zh).
This commit is contained in:
@@ -17,6 +17,7 @@ from ledgrab.api.graph_schema import (
|
||||
find_dependents,
|
||||
graph_field_roots,
|
||||
is_editable,
|
||||
remap_refs,
|
||||
schema_for_kind,
|
||||
serialize_entity_for_graph,
|
||||
validate_connection,
|
||||
@@ -67,6 +68,56 @@ def test_extract_refs_nested_object_none_is_safe():
|
||||
) == ["pt_1"]
|
||||
|
||||
|
||||
# ── remap_refs (write-twin of extract_refs) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_remap_refs_top_level():
|
||||
e = {"device_id": "a"}
|
||||
assert remap_refs(e, "device_id", {"a": "b"}) == 1
|
||||
assert e["device_id"] == "b"
|
||||
|
||||
|
||||
def test_remap_refs_leaves_unmapped_ids_untouched():
|
||||
e = {"device_id": "external"}
|
||||
assert remap_refs(e, "device_id", {"a": "b"}) == 0
|
||||
assert e["device_id"] == "external" # shared dependency outside the set
|
||||
|
||||
|
||||
def test_remap_refs_bindable_bound():
|
||||
e = {"brightness": {"value": 1.0, "source_id": "a"}}
|
||||
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 1
|
||||
assert e["brightness"] == {"value": 1.0, "source_id": "b"}
|
||||
|
||||
|
||||
def test_remap_refs_unbound_bindable_is_safe():
|
||||
e = {"brightness": 0.5} # plain number, no binding
|
||||
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 0
|
||||
assert e["brightness"] == 0.5
|
||||
|
||||
|
||||
def test_remap_refs_list_field_only_mapped_elements():
|
||||
e = {"layers": [{"source_id": "a"}, {"source_id": "external"}, {"source_id": "c"}]}
|
||||
assert remap_refs(e, "layers[].source_id", {"a": "b", "c": "d"}) == 2
|
||||
assert [layer["source_id"] for layer in e["layers"]] == ["b", "external", "d"]
|
||||
|
||||
|
||||
def test_remap_refs_deep_object_then_list():
|
||||
e = {"calibration": {"lines": [{"picture_source_id": "p1"}, {"picture_source_id": "p2"}]}}
|
||||
assert remap_refs(e, "calibration.lines[].picture_source_id", {"p1": "q1"}) == 1
|
||||
assert [ln["picture_source_id"] for ln in e["calibration"]["lines"]] == ["q1", "p2"]
|
||||
|
||||
|
||||
def test_remap_refs_missing_keys_are_safe():
|
||||
assert remap_refs({}, "layers[].source_id", {"a": "b"}) == 0
|
||||
assert remap_refs({"layers": None}, "layers[].source_id", {"a": "b"}) == 0
|
||||
|
||||
|
||||
def test_remap_refs_round_trips_with_extract_refs():
|
||||
e = {"layers": [{"source_id": "a"}, {"source_id": "a"}]}
|
||||
remap_refs(e, "layers[].source_id", {"a": "b"})
|
||||
assert extract_refs(e, "layers[].source_id") == ["b", "b"]
|
||||
|
||||
|
||||
# ── registry consistency ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user