feat: support nesting for composite color strip sources
Lint & Test / test (push) Successful in 2m17s
Lint & Test / test (push) Successful in 2m17s
Allow composite sources to reference other composite/mapped sources as layers. Adds cycle detection (via transitive dependency graph walk), depth limiting (MAX_COMPOSITE_DEPTH=4), and a runtime safety net in the stream manager. Frontend layer dropdown now shows all source types except the source being edited. 17 new tests covering cycles, depth limits, and valid nesting — all 715 tests passing.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Tests for composite nesting: cycle detection, depth limits, transitive dependencies."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore, MAX_COMPOSITE_DEPTH
|
||||
from wled_controller.storage.database import Database
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_db):
|
||||
return ColorStripStore(tmp_db)
|
||||
|
||||
|
||||
def _create_static(store: ColorStripStore, name: str) -> str:
|
||||
"""Create a static color strip source, return its ID."""
|
||||
source = store.create_source(name=name, source_type="static", colors=[[255, 0, 0]])
|
||||
return source.id
|
||||
|
||||
|
||||
def _create_composite(store: ColorStripStore, name: str, layer_ids: list[str]) -> str:
|
||||
"""Create a composite source referencing the given layer IDs, return its ID."""
|
||||
layers = [
|
||||
{"source_id": sid, "blend_mode": "normal", "opacity": 1.0, "enabled": True}
|
||||
for sid in layer_ids
|
||||
]
|
||||
source = store.create_source(name=name, source_type="composite", layers=layers)
|
||||
return source.id
|
||||
|
||||
|
||||
# ── Transitive Dependencies ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitiveDependencies:
|
||||
def test_leaf_has_no_deps(self, store):
|
||||
sid = _create_static(store, "leaf")
|
||||
assert store.get_transitive_dependencies(sid) == set()
|
||||
|
||||
def test_composite_deps_are_its_layers(self, store):
|
||||
a = _create_static(store, "A")
|
||||
b = _create_static(store, "B")
|
||||
comp = _create_composite(store, "comp", [a, b])
|
||||
deps = store.get_transitive_dependencies(comp)
|
||||
assert deps == {a, b}
|
||||
|
||||
def test_nested_composite_transitive_deps(self, store):
|
||||
a = _create_static(store, "A")
|
||||
b = _create_static(store, "B")
|
||||
inner = _create_composite(store, "inner", [a])
|
||||
outer = _create_composite(store, "outer", [inner, b])
|
||||
deps = store.get_transitive_dependencies(outer)
|
||||
assert deps == {inner, a, b}
|
||||
|
||||
def test_nonexistent_id_returns_empty(self, store):
|
||||
assert store.get_transitive_dependencies("css_nonexist") == set()
|
||||
|
||||
|
||||
# ── Nesting Depth ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNestingDepth:
|
||||
def test_leaf_depth_is_zero(self, store):
|
||||
sid = _create_static(store, "leaf")
|
||||
assert store.get_nesting_depth(sid) == 0
|
||||
|
||||
def test_flat_composite_depth_is_one(self, store):
|
||||
a = _create_static(store, "A")
|
||||
comp = _create_composite(store, "comp", [a])
|
||||
assert store.get_nesting_depth(comp) == 1
|
||||
|
||||
def test_two_level_nesting_depth(self, store):
|
||||
a = _create_static(store, "A")
|
||||
inner = _create_composite(store, "inner", [a])
|
||||
outer = _create_composite(store, "outer", [inner])
|
||||
assert store.get_nesting_depth(outer) == 2
|
||||
|
||||
def test_depth_uses_deepest_branch(self, store):
|
||||
a = _create_static(store, "A")
|
||||
b = _create_static(store, "B")
|
||||
inner = _create_composite(store, "inner", [a])
|
||||
outer = _create_composite(store, "outer", [inner, b])
|
||||
# inner has depth 1, b has depth 0 → outer picks max (1) + 1 = 2
|
||||
assert store.get_nesting_depth(outer) == 2
|
||||
|
||||
|
||||
# ── Validate Nesting ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateNesting:
|
||||
def test_self_reference_rejected(self, store):
|
||||
a = _create_static(store, "A")
|
||||
comp = _create_composite(store, "comp", [a])
|
||||
with pytest.raises(ValueError, match="self-reference"):
|
||||
store.validate_nesting(comp, [comp])
|
||||
|
||||
def test_direct_cycle_rejected(self, store):
|
||||
"""A → B → A should be rejected."""
|
||||
a = _create_static(store, "A")
|
||||
comp_b = _create_composite(store, "B", [a])
|
||||
comp_a = _create_composite(store, "A-comp", [comp_b])
|
||||
# Try to add A-comp as a layer of B → would create B → A-comp → B
|
||||
with pytest.raises(ValueError, match="circular reference"):
|
||||
store.validate_nesting(comp_b, [comp_a])
|
||||
|
||||
def test_transitive_cycle_rejected(self, store):
|
||||
"""A → B → C, then trying C → A should be rejected."""
|
||||
leaf = _create_static(store, "leaf")
|
||||
c = _create_composite(store, "C", [leaf])
|
||||
b = _create_composite(store, "B", [c])
|
||||
a = _create_composite(store, "A", [b])
|
||||
# Try to add A as a layer of C → C → A → B → C
|
||||
with pytest.raises(ValueError, match="circular reference"):
|
||||
store.validate_nesting(c, [a])
|
||||
|
||||
def test_valid_nesting_accepted(self, store):
|
||||
"""A → B where B is composite with only leaves should be fine."""
|
||||
leaf = _create_static(store, "leaf")
|
||||
inner = _create_composite(store, "inner", [leaf])
|
||||
outer_id = _create_composite(store, "outer", [])
|
||||
# Should not raise
|
||||
store.validate_nesting(outer_id, [inner])
|
||||
|
||||
def test_depth_limit_exceeded(self, store):
|
||||
"""Build a chain deeper than MAX_COMPOSITE_DEPTH and verify rejection."""
|
||||
# Build chain: leaf → comp1 → comp2 → ... → compN
|
||||
prev = _create_static(store, "leaf")
|
||||
for i in range(MAX_COMPOSITE_DEPTH):
|
||||
prev = _create_composite(store, f"comp_{i}", [prev])
|
||||
# prev is now at depth MAX_COMPOSITE_DEPTH
|
||||
# Trying to wrap it in one more composite should fail
|
||||
wrapper = _create_composite(store, "wrapper", [])
|
||||
with pytest.raises(ValueError, match="Nesting depth"):
|
||||
store.validate_nesting(wrapper, [prev])
|
||||
|
||||
def test_depth_at_limit_accepted(self, store):
|
||||
"""A chain exactly at MAX_COMPOSITE_DEPTH should be accepted."""
|
||||
prev = _create_static(store, "leaf")
|
||||
for i in range(MAX_COMPOSITE_DEPTH - 1):
|
||||
prev = _create_composite(store, f"comp_{i}", [prev])
|
||||
# prev is at depth MAX_COMPOSITE_DEPTH - 1
|
||||
wrapper = _create_composite(store, "wrapper", [])
|
||||
# 1 + (MAX_COMPOSITE_DEPTH - 1) = MAX_COMPOSITE_DEPTH → should pass
|
||||
store.validate_nesting(wrapper, [prev])
|
||||
|
||||
def test_multiple_children_validates_all(self, store):
|
||||
"""If one child creates a cycle, validation fails even if others are fine."""
|
||||
leaf = _create_static(store, "leaf")
|
||||
comp = _create_composite(store, "comp", [leaf])
|
||||
with pytest.raises(ValueError, match="self-reference"):
|
||||
store.validate_nesting(comp, [leaf, comp])
|
||||
|
||||
def test_empty_children_accepted(self, store):
|
||||
"""Empty children list should always pass."""
|
||||
comp = _create_composite(store, "comp", [])
|
||||
store.validate_nesting(comp, [])
|
||||
|
||||
def test_none_children_skipped(self, store):
|
||||
"""Empty-string child IDs should be skipped."""
|
||||
comp = _create_composite(store, "comp", [])
|
||||
store.validate_nesting(comp, ["", ""])
|
||||
Reference in New Issue
Block a user