Files
ledgrab/server/tests/api/test_graph_duplicate.py
T
alexei.dolgolyov 15cfb821d3 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).
2026-05-29 11:45:55 +03:00

123 lines
5.1 KiB
Python

"""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"