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:
2026-05-29 11:45:55 +03:00
parent 2e51f46dfd
commit 15cfb821d3
12 changed files with 476 additions and 2 deletions
+48
View File
@@ -279,6 +279,54 @@ def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
return [v for v in current if isinstance(v, str) and v]
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
Ids absent from ``id_map`` (references to entities outside the remap set) are
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
bindables (a plain number where an object was expected) and missing keys are
tolerated. Returns the number of ids rewritten.
"""
segments = field_path.split(".")
# Descend to the container(s) that hold the final key.
parents: list[Any] = [entity]
for segment in segments[:-1]:
is_list = segment.endswith("[]")
key = segment[:-2] if is_list else segment
nxt: list[Any] = []
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if is_list:
if isinstance(val, list):
nxt.extend(val)
elif isinstance(val, dict):
nxt.append(val)
parents = nxt
last = segments[-1]
last_is_list = last.endswith("[]")
key = last[:-2] if last_is_list else last
count = 0
for obj in parents:
if not isinstance(obj, dict):
continue
val = obj.get(key)
if last_is_list:
if isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and item in id_map:
val[i] = id_map[item]
count += 1
elif isinstance(val, str) and val in id_map:
obj[key] = id_map[val]
count += 1
return count
def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use.
+125
View File
@@ -26,8 +26,12 @@ from ledgrab.api.graph_schema import (
ENTITY_KINDS,
NODE_TYPE_FIELD,
build_topology,
extract_refs,
find_dependents,
remap_refs,
schema_as_dicts,
schema_for_kind,
serialize_entity,
serialize_entity_for_graph,
validate_connection,
)
@@ -122,3 +126,124 @@ async def validate_graph_connection(
entities, body.target_kind, body.target_id, body.field, body.source_id
)
return {"ok": ok, "error": error}
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
# Only these kinds are cloned. They carry no inline secrets — they *reference*
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
# and those are NOT cloned — and they have no hardware identity to conflict
# over. Output targets, automations, devices and integrations are out of scope.
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
_MAX_DUPLICATE = 200
class DuplicateRequest(BaseModel):
"""Duplicate a selected subgraph of value / colour-strip sources."""
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
name_suffix: str = Field(default=" (copy)", max_length=40)
def _unique_name(existing: set[str], desired: str) -> str:
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
if desired not in existing:
return desired
i = 2
while f"{desired} {i}" in existing:
i += 1
return f"{desired} {i}"
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
references that point *within* the selection (shared deps are left alone)."""
# Index every duplicable entity by id → (kind, store, model); track names.
index: dict[str, tuple[str, Any, Any]] = {}
existing_names: dict[str, set[str]] = {}
for kind in _DUPLICABLE_KINDS:
try:
store = _KIND_STORES[kind]()
models = store.get_all()
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
continue
names = existing_names.setdefault(kind, set())
for m in models:
mid = getattr(m, "id", None)
mname = getattr(m, "name", None)
if isinstance(mname, str):
names.add(mname)
if isinstance(mid, str) and mid:
index[mid] = (kind, store, m)
selected: list[str] = []
skipped: list[dict[str, str]] = []
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
if nid in index:
selected.append(nid)
else:
skipped.append(
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
)
# Pass 1 — create clones; their refs still point at the originals (valid).
id_map: dict[str, str] = {}
created: list[dict[str, str]] = []
clones: list[tuple[str, Any, str]] = []
for old_id in selected:
kind, store, model = index[old_id]
base = (getattr(model, "name", None) or old_id) + name_suffix
name = _unique_name(existing_names[kind], base)
existing_names[kind].add(name)
try:
new = store.clone(old_id, name)
except Exception as exc: # noqa: BLE001
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
continue
id_map[old_id] = new.id
created.append({"id": new.id, "kind": kind, "name": new.name})
clones.append((kind, store, new.id))
# Pass 2 — rewrite references that point within the cloned set.
warnings: list[dict[str, str]] = []
for kind, store, new_id in clones:
clone = serialize_entity(store.get(new_id))
changed_roots: set[str] = set()
for cf in schema_for_kind(kind):
if remap_refs(clone, cf.field, id_map):
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
if not changed_roots:
continue
# `clone` is the FULL serialized entity, so each changed root carries a
# complete, structurally-intact value (the whole `layers` list / bindable
# dict) that ``update_source`` replaces or merges wholesale. (Within the
# duplicable set the only roots that change are scalar ids, `layers` and
# bindable slots — never a partially-built nested object.)
updates = {root: clone[root] for root in changed_roots if root in clone}
try:
store.update_source(new_id, **updates)
except Exception as exc: # noqa: BLE001
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
# Safety net — a clone must never still reference an OLD (in-selection) id.
for kind, store, new_id in clones:
clone = serialize_entity(store.get(new_id))
for cf in schema_for_kind(kind):
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
References that point *within* the selection are rewired to the new clones;
references to entities outside it (devices, HA sources, …) stay shared with
the originals. Only value and colour-strip sources are cloned — they carry no
inline secrets — so any other kind in the selection is reported in ``skipped``.
"""
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
+2 -1
View File
@@ -207,7 +207,7 @@ import {
import {
loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection,
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
} from './features/graph-editor.ts';
@@ -627,6 +627,7 @@ Object.assign(window, {
graphRelayout,
graphShowIssues,
graphExportTopology,
graphDuplicateSelection,
graphToggleFullscreen,
graphAddEntity,
toggleToolbarOverflow,
@@ -422,4 +422,36 @@ export async function updateListSlotConnection(
}
}
/* ── Subgraph duplication (D6) ─────────────────────────────────────── */
/** Result of `POST /graph/duplicate` (server-side clone of a selected subgraph). */
export interface DuplicateResult {
id_map: Record<string, string>;
created: Array<{ id: string; kind: string; name: string }>;
skipped: Array<{ id: string; reason: string }>;
warnings: Array<{ id: string; reason: string }>;
}
/**
* Server-side duplicate of a selected subgraph: the backend deep-clones the
* value / colour-strip sources among `nodeIds` with fresh ids and rewires
* references that point *within* the selection (shared deps stay shared).
* Returns the result, or `null` on failure. Invalidates the affected caches so
* a subsequent graph reload shows the clones.
*/
export async function duplicateSubgraph(
nodeIds: string[], nameSuffix?: string,
): Promise<DuplicateResult | null> {
try {
const body: Record<string, unknown> = { node_ids: nodeIds };
if (nameSuffix) body.name_suffix = nameSuffix;
const res = await apiPost<DuplicateResult>('/graph/duplicate', body);
valueSourcesCache.invalidate();
colorStripSourcesCache.invalidate();
return res;
} catch {
return null;
}
}
export { CONNECTION_MAP };
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { t } from '../core/i18n.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, duplicateSubgraph, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
import { showTypePicker } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
@@ -809,6 +809,40 @@ export async function graphExportTopology(): Promise<void> {
}
}
/**
* Duplicate the current node selection server-side. The backend clones the
* value / colour-strip sources in the selection (full config preserved, no
* secrets crossing the wire), rewires references that point *within* the
* selection, and shares everything else. The graph then reloads with the new
* cluster selected.
*/
export async function graphDuplicateSelection(): Promise<void> {
const ids = [..._selectedIds];
if (ids.length === 0) {
showToast(t('graph.duplicate_none'), 'info');
return;
}
const res = await duplicateSubgraph(ids);
if (!res) {
showToast(t('graph.duplicate_failed'), 'error');
return;
}
const newIds = Object.values(res.id_map || {});
if (newIds.length === 0) {
showToast(t('graph.duplicate_none_eligible'), 'info');
return;
}
await loadGraphEditor();
_selectedIds = new Set(newIds);
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
const hasWarn = !!res.warnings?.length;
showToast(
t(hasWarn ? 'graph.duplicate_done_warn' : 'graph.duplicate_done', { count: newIds.length }),
hasWarn ? 'info' : 'success',
);
}
/** Frame and highlight all nodes flagged with configuration issues. */
export function graphShowIssues(): void {
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
@@ -1357,6 +1391,10 @@ function _graphHTML(): string {
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
<span>${t('graph.export')}</span>
</button>
<button onclick="graphDuplicateSelection(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>${t('graph.duplicate')}</span>
</button>
<div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
+1
View File
@@ -379,6 +379,7 @@ startTargetOverlay: (...args: any[]) => any;
graphRelayout: (...args: any[]) => any;
graphShowIssues: (...args: any[]) => any;
graphExportTopology: (...args: any[]) => any;
graphDuplicateSelection: (...args: any[]) => any;
graphToggleFullscreen: (...args: any[]) => any;
graphAddEntity: (...args: any[]) => any;
@@ -2604,6 +2604,12 @@
"graph.export": "Export graph (JSON)",
"graph.export_done": "Graph exported",
"graph.export_failed": "Failed to export graph",
"graph.duplicate": "Duplicate selection",
"graph.duplicate_none": "Select one or more nodes to duplicate",
"graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value & colour-strip sources)",
"graph.duplicate_done": "Duplicated {count} source(s)",
"graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped",
"graph.duplicate_failed": "Failed to duplicate selection",
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
"automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled",
@@ -2286,6 +2286,12 @@
"graph.export": "Экспорт графа (JSON)",
"graph.export_done": "Граф экспортирован",
"graph.export_failed": "Не удалось экспортировать граф",
"graph.duplicate": "Дублировать выбранное",
"graph.duplicate_none": "Выберите один или несколько узлов для дублирования",
"graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)",
"graph.duplicate_done": "Продублировано источников: {count}",
"graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать",
"graph.duplicate_failed": "Не удалось дублировать выбранное",
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
"automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена",
@@ -2282,6 +2282,12 @@
"graph.export": "导出图谱 (JSON)",
"graph.export_done": "图谱已导出",
"graph.export_failed": "导出图谱失败",
"graph.duplicate": "复制所选",
"graph.duplicate_none": "请选择一个或多个节点以复制",
"graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)",
"graph.duplicate_done": "已复制 {count} 个源",
"graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射",
"graph.duplicate_failed": "复制所选失败",
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
"automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用",
@@ -6,7 +6,10 @@ writes go through to SQLite immediately (write-through cache).
"""
import asyncio
import copy
import threading
import uuid
from datetime import datetime, timezone
from typing import Callable, Dict, Generic, List, TypeVar
from ledgrab.storage.database import Database
@@ -136,6 +139,41 @@ class BaseSqliteStore(Generic[T]):
await asyncio.to_thread(self._delete_item, item_id)
logger.info(f"Deleted {self._entity_name}: {item_id}")
def clone(self, item_id: str, new_name: str) -> T:
"""Faithfully duplicate an entity under a new id and name.
Deep-copies every field of the original (no serialize/deserialize
round-trip, so no field can be silently lost to a schema/dataclass name
mismatch), mints a fresh id that preserves the original's id prefix,
applies ``new_name`` and resets timestamps. References *inside* the clone
still point at whatever the original referenced — callers that want to
rewire intra-set references must do so after cloning.
SECURITY: this copies *every* field verbatim, including any secret a
model might hold. Callers must restrict cloning to non-secret-bearing
kinds (see ``_DUPLICABLE_KINDS`` in ``api/routes/graph.py``).
"""
with self._lock:
if item_id not in self._items:
from ledgrab.storage.base_store import EntityNotFoundError
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
self._check_name_unique(new_name)
new = copy.deepcopy(self._items[item_id])
prefix = item_id.rsplit("_", 1)[0] if "_" in item_id else item_id
new_id = f"{prefix}_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc)
new.id = new_id
new.name = new_name
if hasattr(new, "created_at"):
new.created_at = now
if hasattr(new, "updated_at"):
new.updated_at = now
self._items[new_id] = new
self._save_item(new_id, new)
logger.info("Cloned %s %s -> %s (%s)", self._entity_name, item_id, new_id, new_name)
return new
def count(self) -> int:
with self._lock:
return len(self._items)
+122
View File
@@ -0,0 +1,122 @@
"""Integration tests for server-side subgraph duplication.
Exercises ``_duplicate_subgraph`` against the *real* value-source and
colour-strip stores (temp DB), asserting that references *within* the selection
are remapped to the clones while references to entities *outside* the selection
stay shared with the originals — and that the deep-copy clone preserves config.
"""
import pytest
from ledgrab.api import dependencies as deps
from ledgrab.api.graph_schema import serialize_entity
from ledgrab.api.routes.graph import _duplicate_subgraph
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.database import Database
from ledgrab.storage.value_source_store import ValueSourceStore
@pytest.fixture
def stores(tmp_path, monkeypatch):
db = Database(tmp_path / "dup.db")
css = ColorStripStore(db)
vss = ValueSourceStore(db)
monkeypatch.setattr(deps, "_deps", {"color_strip_store": css, "value_source_store": vss})
yield css, vss
db.close()
def _layer(sid: str) -> dict:
return {"source_id": sid, "blend_mode": "normal", "opacity": 1.0, "enabled": True}
def test_duplicate_composite_remaps_only_in_selection_refs(stores):
css, _vss = stores
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
d = css.create_source(name="D", source_type="single_color", colors=[[0, 255, 0]])
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id), _layer(d.id)])
res = _duplicate_subgraph([a.id, b.id], " (copy)")
assert set(res["id_map"]) == {a.id, b.id}
assert res["skipped"] == []
assert res["warnings"] == []
a_new = serialize_entity(css.get(res["id_map"][a.id]))
layer_ids = [ly["source_id"] for ly in a_new["layers"]]
# B was in the selection -> remapped to the clone; D was not -> stays shared.
assert layer_ids == [res["id_map"][b.id], d.id]
assert a_new["name"] == "A (copy)"
# Original is untouched.
assert [ly["source_id"] for ly in serialize_entity(css.get(a.id))["layers"]] == [b.id, d.id]
def test_duplicate_preserves_clone_config(stores):
"""The deep-copy clone keeps every field except identity/name/timestamps —
guarding against the storage-vs-create-schema name mismatch (e.g. single
color's ``colors``) that a create-schema round-trip would silently drop."""
css, _vss = stores
src = css.create_source(name="Solid", source_type="single_color", colors=[[12, 34, 56]])
res = _duplicate_subgraph([src.id], " (copy)")
orig = serialize_entity(css.get(src.id))
clone = serialize_entity(css.get(res["id_map"][src.id]))
ignore = {"id", "name", "created_at", "updated_at"}
assert {k: v for k, v in clone.items() if k not in ignore} == {
k: v for k, v in orig.items() if k not in ignore
}
def test_duplicate_partial_selection_keeps_layer_refs_shared(stores):
css, _vss = stores
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id)])
res = _duplicate_subgraph([a.id], " (copy)") # only the composite, not its layer
a_new = serialize_entity(css.get(res["id_map"][a.id]))
assert [ly["source_id"] for ly in a_new["layers"]] == [b.id] # shared with original
def test_duplicate_remaps_bindable_slot_to_cloned_value_source(stores):
"""Pass-2's dict round-trip path: a colour-strip bindable slot bound to a
value source that is *also* in the selection must point at the value clone
(not the original) after duplication."""
css, vss = stores
vs = vss.create_source(name="Pulse", source_type="static", value=0.5)
c = css.create_source(name="Candle", source_type="candlelight")
css.update_source(c.id, intensity={"source_id": vs.id}) # bind intensity -> vs
assert serialize_entity(css.get(c.id))["intensity"]["source_id"] == vs.id # binding took
res = _duplicate_subgraph([c.id, vs.id], " (copy)")
assert res["warnings"] == []
c_new = serialize_entity(css.get(res["id_map"][c.id]))
assert c_new["intensity"]["source_id"] == res["id_map"][vs.id]
def test_duplicate_value_source(stores):
_css, vss = stores
v = vss.create_source(name="V", source_type="static", value=0.5)
res = _duplicate_subgraph([v.id], " (copy)")
assert list(res["id_map"]) == [v.id]
new = vss.get(res["id_map"][v.id])
assert new.id != v.id
assert new.name == "V (copy)"
assert getattr(new, "value", None) == 0.5
def test_duplicate_skips_non_duplicable_ids(stores):
_css, vss = stores
v = vss.create_source(name="V", source_type="static", value=0.5)
res = _duplicate_subgraph([v.id, "dev_external", "bogus"], " (copy)")
assert list(res["id_map"]) == [v.id]
assert {s["id"] for s in res["skipped"]} == {"dev_external", "bogus"}
def test_duplicate_name_collision_is_suffixed(stores):
_css, vss = stores
v = vss.create_source(name="V", source_type="static", value=0.5)
vss.create_source(name="V (copy)", source_type="static", value=0.1) # occupy the obvious name
res = _duplicate_subgraph([v.id], " (copy)")
new = vss.get(res["id_map"][v.id])
assert new.name == "V (copy) 2"
+51
View File
@@ -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 ─────────────────────────────────────────────────────