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] 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]: def serialize_entity(model: Any) -> dict[str, Any]:
"""Best-effort serialize a storage model to a plain dict for graph use. """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, ENTITY_KINDS,
NODE_TYPE_FIELD, NODE_TYPE_FIELD,
build_topology, build_topology,
extract_refs,
find_dependents, find_dependents,
remap_refs,
schema_as_dicts, schema_as_dicts,
schema_for_kind,
serialize_entity,
serialize_entity_for_graph, serialize_entity_for_graph,
validate_connection, validate_connection,
) )
@@ -122,3 +126,124 @@ async def validate_graph_connection(
entities, body.target_kind, body.target_id, body.field, body.source_id entities, body.target_kind, body.target_id, body.field, body.source_id
) )
return {"ok": ok, "error": error} 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 { import {
loadGraphEditor, loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo, toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection,
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow, graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
} from './features/graph-editor.ts'; } from './features/graph-editor.ts';
@@ -627,6 +627,7 @@ Object.assign(window, {
graphRelayout, graphRelayout,
graphShowIssues, graphShowIssues,
graphExportTopology, graphExportTopology,
graphDuplicateSelection,
graphToggleFullscreen, graphToggleFullscreen,
graphAddEntity, graphAddEntity,
toggleToolbarOverflow, 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 }; export { CONNECTION_MAP };
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts'; import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts'; import { createFpsSparkline } from '../core/chart-utils.ts';
import { t } from '../core/i18n.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 { showTypePicker } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.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. */ /** Frame and highlight all nodes flagged with configuration issues. */
export function graphShowIssues(): void { export function graphShowIssues(): void {
if (_issueIds.size === 0 || !_nodeMap || !_canvas) { 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> <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> <span>${t('graph.export')}</span>
</button> </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> <div class="graph-overflow-sep"></div>
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()"> <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> <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; graphRelayout: (...args: any[]) => any;
graphShowIssues: (...args: any[]) => any; graphShowIssues: (...args: any[]) => any;
graphExportTopology: (...args: any[]) => any; graphExportTopology: (...args: any[]) => any;
graphDuplicateSelection: (...args: any[]) => any;
graphToggleFullscreen: (...args: any[]) => any; graphToggleFullscreen: (...args: any[]) => any;
graphAddEntity: (...args: any[]) => any; graphAddEntity: (...args: any[]) => any;
@@ -2604,6 +2604,12 @@
"graph.export": "Export graph (JSON)", "graph.export": "Export graph (JSON)",
"graph.export_done": "Graph exported", "graph.export_done": "Graph exported",
"graph.export_failed": "Failed to export graph", "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?", "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.enabled": "Automation enabled",
"automation.disabled": "Automation disabled", "automation.disabled": "Automation disabled",
@@ -2286,6 +2286,12 @@
"graph.export": "Экспорт графа (JSON)", "graph.export": "Экспорт графа (JSON)",
"graph.export_done": "Граф экспортирован", "graph.export_done": "Граф экспортирован",
"graph.export_failed": "Не удалось экспортировать граф", "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}. Удалить и разорвать эти связи?", "graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
"automation.enabled": "Автоматизация включена", "automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена", "automation.disabled": "Автоматизация выключена",
@@ -2282,6 +2282,12 @@
"graph.export": "导出图谱 (JSON)", "graph.export": "导出图谱 (JSON)",
"graph.export_done": "图谱已导出", "graph.export_done": "图谱已导出",
"graph.export_failed": "导出图谱失败", "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}。删除并断开这些连接?", "graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
"automation.enabled": "自动化已启用", "automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用", "automation.disabled": "自动化已禁用",
@@ -6,7 +6,10 @@ writes go through to SQLite immediately (write-through cache).
""" """
import asyncio import asyncio
import copy
import threading import threading
import uuid
from datetime import datetime, timezone
from typing import Callable, Dict, Generic, List, TypeVar from typing import Callable, Dict, Generic, List, TypeVar
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
@@ -136,6 +139,41 @@ class BaseSqliteStore(Generic[T]):
await asyncio.to_thread(self._delete_item, item_id) await asyncio.to_thread(self._delete_item, item_id)
logger.info(f"Deleted {self._entity_name}: {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: def count(self) -> int:
with self._lock: with self._lock:
return len(self._items) 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, find_dependents,
graph_field_roots, graph_field_roots,
is_editable, is_editable,
remap_refs,
schema_for_kind, schema_for_kind,
serialize_entity_for_graph, serialize_entity_for_graph,
validate_connection, validate_connection,
@@ -67,6 +68,56 @@ def test_extract_refs_nested_object_none_is_safe():
) == ["pt_1"] ) == ["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 ───────────────────────────────────────────────────── # ── registry consistency ─────────────────────────────────────────────────────