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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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