diff --git a/tests/test_execute_recipe.py b/tests/test_execute_recipe.py index bd3ae1d..647192b 100644 --- a/tests/test_execute_recipe.py +++ b/tests/test_execute_recipe.py @@ -244,3 +244,174 @@ class TestExecuteRecipeIntegration: # User source resolves from input_hashes user_cid = resolve_source_cid(user_source.config, input_hashes) assert user_cid == "QmUserProvidedVideo456" + + +class TestCompoundNodeHandling: + """ + Tests for COMPOUND node handling. + + COMPOUND nodes are collapsed effect chains that should be executed + sequentially through their respective effect executors. + + Bug fixed: "No executor for node type: COMPOUND" + """ + + def test_compound_node_has_filter_chain(self): + """COMPOUND nodes must have a filter_chain config.""" + step = MockStep( + step_id="compound_1", + node_type="COMPOUND", + config={ + "filter_chain": [ + {"type": "EFFECT", "config": {"effect": "identity"}}, + {"type": "EFFECT", "config": {"effect": "dog"}}, + ], + "inputs": ["source_1"], + }, + cache_id="compound_cache", + level=1, + ) + + assert step.node_type == "COMPOUND" + assert "filter_chain" in step.config + assert len(step.config["filter_chain"]) == 2 + + def test_compound_filter_chain_has_effects(self): + """COMPOUND filter_chain should contain EFFECT items with effect names.""" + filter_chain = [ + {"type": "EFFECT", "config": {"effect": "identity", "cid": "Qm123"}}, + {"type": "EFFECT", "config": {"effect": "dog", "cid": "Qm456"}}, + ] + + for item in filter_chain: + assert item["type"] == "EFFECT" + assert "effect" in item["config"] + assert "cid" in item["config"] + + def test_compound_requires_inputs(self): + """COMPOUND nodes must have input steps.""" + step = MockStep( + step_id="compound_1", + node_type="COMPOUND", + config={"filter_chain": [], "inputs": []}, + cache_id="compound_cache", + input_steps=[], + level=1, + ) + + # Empty inputs should be detected as error + assert len(step.input_steps) == 0 + # The execute_recipe should raise ValueError for empty inputs + + +class TestCacheIdLookup: + """ + Tests for cache lookup by code-addressed cache_id. + + Bug fixed: Cache lookups by cache_id (code hash) were failing because + only IPFS CID was indexed. Now we also index by node_id when different. + """ + + def test_cache_id_is_code_addressed(self): + """cache_id should be a SHA3-256 hash (64 hex chars), not IPFS CID.""" + # Code-addressed hash example + cache_id = "5702aaec14adaddda9baefa94d5842143749ee19e6bb7c1fa7068dce21f51ed4" + + assert len(cache_id) == 64 + assert all(c in "0123456789abcdef" for c in cache_id) + assert not cache_id.startswith("Qm") # Not IPFS CID + + def test_ipfs_cid_format(self): + """IPFS CIDs start with 'Qm' (v0) or 'bafy' (v1).""" + ipfs_cid_v0 = "QmXrj6tSSn1vQXxxEY2Tyoudvt4CeeqR9gGQwSt7WFrhMZ" + ipfs_cid_v1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + + assert ipfs_cid_v0.startswith("Qm") + assert ipfs_cid_v1.startswith("bafy") + + def test_cache_id_differs_from_ipfs_cid(self): + """ + Code-addressed cache_id is computed BEFORE execution. + IPFS CID is computed AFTER execution from file content. + They will differ for the same logical step. + """ + # Same step can have: + cache_id = "5702aaec14adaddda9baefa94d5842143749ee19e6bb7c1fa7068dce21f51ed4" + ipfs_cid = "QmXrj6tSSn1vQXxxEY2Tyoudvt4CeeqR9gGQwSt7WFrhMZ" + + assert cache_id != ipfs_cid + # Both should be indexable for cache lookups + + +class TestPlanStepTypes: + """ + Tests verifying all node types in S-expression plans are handled. + + These tests document the node types that execute_recipe must handle. + """ + + def test_source_node_types(self): + """SOURCE nodes: fixed asset or user input.""" + # Fixed asset source + fixed = MockStep("s1", "SOURCE", {"cid": "Qm123"}, "cache1") + assert fixed.node_type == "SOURCE" + assert "cid" in fixed.config + + # User input source + user = MockStep("s2", "SOURCE", {"input": True, "name": "video"}, "cache2") + assert user.node_type == "SOURCE" + assert user.config.get("input") is True + + def test_effect_node_type(self): + """EFFECT nodes: single effect application.""" + step = MockStep( + "e1", "EFFECT", + {"effect": "invert", "cid": "QmEffect123", "intensity": 1.0}, + "cache3" + ) + assert step.node_type == "EFFECT" + assert "effect" in step.config + + def test_compound_node_type(self): + """COMPOUND nodes: collapsed effect chains.""" + step = MockStep( + "c1", "COMPOUND", + {"filter_chain": [{"type": "EFFECT", "config": {}}]}, + "cache4" + ) + assert step.node_type == "COMPOUND" + assert "filter_chain" in step.config + + def test_sequence_node_type(self): + """SEQUENCE nodes: concatenate multiple clips.""" + step = MockStep( + "seq1", "SEQUENCE", + {"transition": {"type": "cut"}}, + "cache5", + input_steps=["clip1", "clip2"] + ) + assert step.node_type == "SEQUENCE" + + +class TestExecuteRecipeErrorHandling: + """Tests for error handling in execute_recipe.""" + + def test_missing_input_hash_error_message(self): + """Error should list available input keys when source not found.""" + config = {"input": True, "name": "Unknown Video"} + input_hashes = {"video-a": "Qm1", "video-b": "Qm2"} + + with pytest.raises(ValueError) as excinfo: + resolve_source_cid(config, input_hashes) + + error_msg = str(excinfo.value) + assert "Unknown Video" in error_msg + assert "video-a" in error_msg or "video-b" in error_msg + + def test_source_no_cid_no_input_error(self): + """SOURCE without cid or input flag should return None (invalid).""" + config = {"name": "broken-source"} # Missing both cid and input + input_hashes = {} + + result = resolve_source_cid(config, input_hashes) + assert result is None # execute_recipe should catch this