"""Tests for composite nesting: cycle detection, depth limits, transitive dependencies.""" import pytest from ledgrab.storage.color_strip_store import ColorStripStore, MAX_COMPOSITE_DEPTH from ledgrab.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 single-color strip source, return its ID.""" source = store.create_source(name=name, source_type="single_color", 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, ["", ""])