Files
rose-ash/tests/test_effect_loading.py
giles 80c94ebea7 Squashed 'l1/' content from commit 670aa58
git-subtree-dir: l1
git-subtree-split: 670aa582df99e87fca7c247b949baf452e8c234f
2026-02-24 23:07:19 +00:00

328 lines
12 KiB
Python

"""
Tests for effect loading from cache and IPFS.
These tests verify that:
- Effects can be loaded from the local cache directory
- IPFS gateway configuration is correct for Docker environments
- The effect executor correctly resolves CIDs from config
"""
import os
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
from unittest.mock import patch, MagicMock
import pytest
# Minimal effect loading implementation for testing
# This mirrors the logic in artdag/nodes/effect.py
def get_effects_cache_dir_impl(env_vars: Dict[str, str]) -> Optional[Path]:
"""Get the effects cache directory from environment or default."""
for env_var in ["CACHE_DIR", "ARTDAG_CACHE_DIR"]:
cache_dir = env_vars.get(env_var)
if cache_dir:
effects_dir = Path(cache_dir) / "_effects"
if effects_dir.exists():
return effects_dir
# Try default locations
for base in [Path.home() / ".artdag" / "cache", Path("/var/cache/artdag")]:
effects_dir = base / "_effects"
if effects_dir.exists():
return effects_dir
return None
def effect_path_for_cid(effects_dir: Path, effect_cid: str) -> Path:
"""Get the expected path for an effect given its CID."""
return effects_dir / effect_cid / "effect.py"
class TestEffectCacheDirectory:
"""Tests for effect cache directory resolution."""
def test_cache_dir_from_env(self, tmp_path: Path) -> None:
"""CACHE_DIR env var should determine effects directory."""
effects_dir = tmp_path / "_effects"
effects_dir.mkdir(parents=True)
env = {"CACHE_DIR": str(tmp_path)}
result = get_effects_cache_dir_impl(env)
assert result == effects_dir
def test_artdag_cache_dir_fallback(self, tmp_path: Path) -> None:
"""ARTDAG_CACHE_DIR should work as fallback."""
effects_dir = tmp_path / "_effects"
effects_dir.mkdir(parents=True)
env = {"ARTDAG_CACHE_DIR": str(tmp_path)}
result = get_effects_cache_dir_impl(env)
assert result == effects_dir
def test_no_env_returns_none_if_no_default_exists(self) -> None:
"""Should return None if no cache directory exists."""
env = {}
result = get_effects_cache_dir_impl(env)
# Will return None unless default dirs exist
# This is expected behavior
if result is not None:
assert result.exists()
class TestEffectPathResolution:
"""Tests for effect path resolution."""
def test_effect_path_structure(self, tmp_path: Path) -> None:
"""Effect should be at _effects/{cid}/effect.py."""
effects_dir = tmp_path / "_effects"
effect_cid = "QmTestEffect123"
path = effect_path_for_cid(effects_dir, effect_cid)
assert path == effects_dir / effect_cid / "effect.py"
def test_effect_file_exists_after_upload(self, tmp_path: Path) -> None:
"""After upload, effect.py should exist in the right location."""
effects_dir = tmp_path / "_effects"
effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
# Simulate effect upload (as done by app/routers/effects.py)
effect_dir = effects_dir / effect_cid
effect_dir.mkdir(parents=True)
effect_source = '''"""
@effect invert
@version 1.0.0
"""
def process_frame(frame, params, state):
return 255 - frame, state
'''
(effect_dir / "effect.py").write_text(effect_source)
# Verify the path structure
expected_path = effect_path_for_cid(effects_dir, effect_cid)
assert expected_path.exists()
assert "process_frame" in expected_path.read_text()
class TestIPFSAPIConfiguration:
"""Tests for IPFS API configuration (consistent across codebase)."""
def test_ipfs_api_multiaddr_conversion(self) -> None:
"""
IPFS_API multiaddr should convert to correct URL.
Both ipfs_client.py and artdag/nodes/effect.py now use IPFS_API
with multiaddr format for consistency.
"""
import re
def multiaddr_to_url(multiaddr: str) -> str:
"""Convert multiaddr to URL (same logic as ipfs_client.py)."""
dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr)
if dns_match:
return f"http://{dns_match.group(1)}:{dns_match.group(2)}"
ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr)
if ip4_match:
return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}"
return "http://127.0.0.1:5001"
# Docker config
docker_api = "/dns/ipfs/tcp/5001"
url = multiaddr_to_url(docker_api)
assert url == "http://ipfs:5001"
# Local dev config
local_api = "/ip4/127.0.0.1/tcp/5001"
url = multiaddr_to_url(local_api)
assert url == "http://127.0.0.1:5001"
def test_all_ipfs_access_uses_api_not_gateway(self) -> None:
"""
All IPFS access should use IPFS_API (port 5001), not IPFS_GATEWAY (port 8080).
Fixed 2026-01-12: artdag/nodes/effect.py was using a separate IPFS_GATEWAY
variable. Now it uses IPFS_API like ipfs_client.py for consistency.
"""
# The API endpoint that both modules use
api_endpoint = "/api/v0/cat"
# This is correct - using the API
assert "api/v0" in api_endpoint
# Gateway endpoint would be /ipfs/{cid} - we don't use this anymore
gateway_pattern = "/ipfs/"
assert gateway_pattern not in api_endpoint
class TestEffectExecutorConfigResolution:
"""Tests for how the effect executor resolves CID from config."""
def test_executor_should_use_cid_key(self) -> None:
"""
Effect executor must look for 'cid' key in config.
The transform_node function sets config["cid"] for effects.
The executor must read from the same key.
"""
config = {
"effect": "invert",
"cid": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J",
"intensity": 1.0,
}
# Simulate executor CID extraction (from artdag/nodes/effect.py:258)
effect_cid = config.get("cid") or config.get("hash")
assert effect_cid == "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
def test_executor_should_not_use_effect_hash(self) -> None:
"""
Regression test: 'effect_hash' is not a valid config key.
Bug found 2026-01-12: transform_node was using config["effect_hash"]
but executor only checks config["cid"] or config["hash"].
"""
config = {
"effect": "invert",
"effect_hash": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J",
}
# This simulates the buggy behavior where effect_hash was set
# but executor doesn't look for it
effect_cid = config.get("cid") or config.get("hash")
# The bug: effect_hash is ignored, effect_cid is None
assert effect_cid is None, "effect_hash should NOT be recognized"
def test_hash_key_is_legacy_fallback(self) -> None:
"""'hash' key should work as legacy fallback for 'cid'."""
config = {
"effect": "invert",
"hash": "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J",
}
effect_cid = config.get("cid") or config.get("hash")
assert effect_cid == "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
class TestEffectLoadingIntegration:
"""Integration tests for complete effect loading path."""
def test_effect_loads_from_cache_when_present(self, tmp_path: Path) -> None:
"""Effect should load from cache without hitting IPFS."""
effects_dir = tmp_path / "_effects"
effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
# Create effect file in cache
effect_dir = effects_dir / effect_cid
effect_dir.mkdir(parents=True)
(effect_dir / "effect.py").write_text('''
def process_frame(frame, params, state):
"""Invert colors."""
return 255 - frame, state
''')
# Verify the effect can be found
effect_path = effect_path_for_cid(effects_dir, effect_cid)
assert effect_path.exists()
# Load and verify it has the expected function
import importlib.util
spec = importlib.util.spec_from_file_location("test_effect", effect_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert hasattr(module, "process_frame")
def test_effect_fetch_uses_ipfs_api(self, tmp_path: Path) -> None:
"""Effect fetch should use IPFS API endpoint, not gateway."""
import re
def multiaddr_to_url(multiaddr: str) -> str:
dns_match = re.match(r"/dns[46]?/([^/]+)/tcp/(\d+)", multiaddr)
if dns_match:
return f"http://{dns_match.group(1)}:{dns_match.group(2)}"
ip4_match = re.match(r"/ip4/([^/]+)/tcp/(\d+)", multiaddr)
if ip4_match:
return f"http://{ip4_match.group(1)}:{ip4_match.group(2)}"
return "http://127.0.0.1:5001"
# In Docker, IPFS_API=/dns/ipfs/tcp/5001
docker_multiaddr = "/dns/ipfs/tcp/5001"
base_url = multiaddr_to_url(docker_multiaddr)
effect_cid = "QmTestCid123"
# Should use API endpoint
api_url = f"{base_url}/api/v0/cat?arg={effect_cid}"
assert "ipfs:5001" in api_url
assert "/api/v0/cat" in api_url
assert "127.0.0.1" not in api_url
class TestSharedVolumeScenario:
"""
Tests simulating the Docker shared volume scenario.
In Docker:
- l1-server uploads effect to /data/cache/_effects/{cid}/effect.py
- l1-worker should find it at the same path via shared volume
"""
def test_effect_visible_on_shared_volume(self, tmp_path: Path) -> None:
"""Effect uploaded on server should be visible to worker."""
# Simulate shared volume mounted at /data/cache on both containers
shared_volume = tmp_path / "data" / "cache"
effects_dir = shared_volume / "_effects"
# Server uploads effect
effect_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
effect_upload_dir = effects_dir / effect_cid
effect_upload_dir.mkdir(parents=True)
(effect_upload_dir / "effect.py").write_text('def process_frame(f, p, s): return f, s')
(effect_upload_dir / "metadata.json").write_text('{"cid": "' + effect_cid + '"}')
# Worker should find the effect
env_vars = {"CACHE_DIR": str(shared_volume)}
worker_effects_dir = get_effects_cache_dir_impl(env_vars)
assert worker_effects_dir is not None
assert worker_effects_dir == effects_dir
worker_effect_path = effect_path_for_cid(worker_effects_dir, effect_cid)
assert worker_effect_path.exists()
def test_effect_cid_matches_registry(self, tmp_path: Path) -> None:
"""CID in recipe registry must match the uploaded effect directory name."""
shared_volume = tmp_path
effects_dir = shared_volume / "_effects"
# The CID used in the recipe registry
registry_cid = "QmPWaW5E5WFrmDjT6w8enqvtJhM8c5jvQu7XN1doHA3Z7J"
# Upload creates directory with CID as name
effect_upload_dir = effects_dir / registry_cid
effect_upload_dir.mkdir(parents=True)
(effect_upload_dir / "effect.py").write_text('def process_frame(f, p, s): return f, s')
# Executor receives the same CID from DAG config
dag_config_cid = registry_cid # This comes from transform_node
# These must match for the lookup to work
assert dag_config_cid == registry_cid
# And the path must exist
lookup_path = effects_dir / dag_config_cid / "effect.py"
assert lookup_path.exists()