cc9900d801
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.
170 lines
7.1 KiB
Python
170 lines
7.1 KiB
Python
"""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, ["", ""])
|