Squashed 'core/' content from commit 4957443
git-subtree-dir: core git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
This commit is contained in:
464
tests/test_engine.py
Normal file
464
tests/test_engine.py
Normal file
@@ -0,0 +1,464 @@
|
||||
# tests/test_primitive_new/test_engine.py
|
||||
"""Tests for primitive engine execution."""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from artdag.dag import DAG, DAGBuilder, Node, NodeType
|
||||
from artdag.engine import Engine
|
||||
from artdag import nodes # Register executors
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_dir():
|
||||
"""Create temporary cache directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(cache_dir):
|
||||
"""Create engine instance."""
|
||||
return Engine(cache_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_video(cache_dir):
|
||||
"""Create a test video file."""
|
||||
video_path = cache_dir / "test_video.mp4"
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc=duration=5:size=320x240:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=5",
|
||||
"-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-c:a", "aac",
|
||||
str(video_path)
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return video_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_audio(cache_dir):
|
||||
"""Create a test audio file."""
|
||||
audio_path = cache_dir / "test_audio.mp3"
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:duration=5",
|
||||
"-c:a", "libmp3lame",
|
||||
str(audio_path)
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return audio_path
|
||||
|
||||
|
||||
class TestEngineBasic:
|
||||
"""Test basic engine functionality."""
|
||||
|
||||
def test_engine_creation(self, cache_dir):
|
||||
"""Test engine creation."""
|
||||
engine = Engine(cache_dir)
|
||||
assert engine.cache is not None
|
||||
|
||||
def test_invalid_dag(self, engine):
|
||||
"""Test executing invalid DAG."""
|
||||
dag = DAG() # No nodes, no output
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert not result.success
|
||||
assert "Invalid DAG" in result.error
|
||||
|
||||
def test_missing_executor(self, engine):
|
||||
"""Test executing node with missing executor."""
|
||||
dag = DAG()
|
||||
node = Node(node_type="UNKNOWN_TYPE", config={})
|
||||
node_id = dag.add_node(node)
|
||||
dag.set_output(node_id)
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert not result.success
|
||||
assert "No executor" in result.error
|
||||
|
||||
|
||||
class TestSourceExecutor:
|
||||
"""Test SOURCE node executor."""
|
||||
|
||||
def test_source_creates_symlink(self, engine, test_video):
|
||||
"""Test source node creates symlink."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
builder.set_output(source)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
assert result.output_path.is_symlink()
|
||||
|
||||
def test_source_missing_file(self, engine):
|
||||
"""Test source with missing file."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source("/nonexistent/file.mp4")
|
||||
builder.set_output(source)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert not result.success
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
|
||||
class TestSegmentExecutor:
|
||||
"""Test SEGMENT node executor."""
|
||||
|
||||
def test_segment_duration(self, engine, test_video):
|
||||
"""Test segment extracts correct duration."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
segment = builder.segment(source, duration=2.0)
|
||||
builder.set_output(segment)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
|
||||
# Verify duration
|
||||
probe = subprocess.run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
str(result.output_path)
|
||||
], capture_output=True, text=True)
|
||||
duration = float(probe.stdout.strip())
|
||||
assert abs(duration - 2.0) < 0.1
|
||||
|
||||
def test_segment_with_offset(self, engine, test_video):
|
||||
"""Test segment with offset."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
segment = builder.segment(source, offset=1.0, duration=2.0)
|
||||
builder.set_output(segment)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
assert result.success
|
||||
|
||||
|
||||
class TestResizeExecutor:
|
||||
"""Test RESIZE node executor."""
|
||||
|
||||
def test_resize_dimensions(self, engine, test_video):
|
||||
"""Test resize to specific dimensions."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
resized = builder.resize(source, width=640, height=480, mode="fit")
|
||||
builder.set_output(resized)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
|
||||
# Verify dimensions
|
||||
probe = subprocess.run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0:s=x",
|
||||
str(result.output_path)
|
||||
], capture_output=True, text=True)
|
||||
dimensions = probe.stdout.strip().split("\n")[0]
|
||||
assert "640x480" in dimensions
|
||||
|
||||
|
||||
class TestTransformExecutor:
|
||||
"""Test TRANSFORM node executor."""
|
||||
|
||||
def test_transform_saturation(self, engine, test_video):
|
||||
"""Test transform with saturation effect."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
transformed = builder.transform(source, effects={"saturation": 1.5})
|
||||
builder.set_output(transformed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
|
||||
def test_transform_multiple_effects(self, engine, test_video):
|
||||
"""Test transform with multiple effects."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
transformed = builder.transform(source, effects={
|
||||
"saturation": 1.2,
|
||||
"contrast": 1.1,
|
||||
"brightness": 0.05,
|
||||
})
|
||||
builder.set_output(transformed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
assert result.success
|
||||
|
||||
|
||||
class TestSequenceExecutor:
|
||||
"""Test SEQUENCE node executor."""
|
||||
|
||||
def test_sequence_cut(self, engine, test_video):
|
||||
"""Test sequence with cut transition."""
|
||||
builder = DAGBuilder()
|
||||
s1 = builder.source(str(test_video))
|
||||
seg1 = builder.segment(s1, duration=2.0)
|
||||
seg2 = builder.segment(s1, offset=2.0, duration=2.0)
|
||||
seq = builder.sequence([seg1, seg2], transition={"type": "cut"})
|
||||
builder.set_output(seq)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
|
||||
# Verify combined duration
|
||||
probe = subprocess.run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
str(result.output_path)
|
||||
], capture_output=True, text=True)
|
||||
duration = float(probe.stdout.strip())
|
||||
assert abs(duration - 4.0) < 0.2
|
||||
|
||||
def test_sequence_crossfade(self, engine, test_video):
|
||||
"""Test sequence with crossfade transition."""
|
||||
builder = DAGBuilder()
|
||||
s1 = builder.source(str(test_video))
|
||||
seg1 = builder.segment(s1, duration=3.0)
|
||||
seg2 = builder.segment(s1, offset=1.0, duration=3.0)
|
||||
seq = builder.sequence([seg1, seg2], transition={"type": "crossfade", "duration": 0.5})
|
||||
builder.set_output(seq)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
|
||||
# Duration should be sum minus crossfade
|
||||
probe = subprocess.run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
str(result.output_path)
|
||||
], capture_output=True, text=True)
|
||||
duration = float(probe.stdout.strip())
|
||||
# 3 + 3 - 0.5 = 5.5
|
||||
assert abs(duration - 5.5) < 0.3
|
||||
|
||||
|
||||
class TestMuxExecutor:
|
||||
"""Test MUX node executor."""
|
||||
|
||||
def test_mux_video_audio(self, engine, test_video, test_audio):
|
||||
"""Test muxing video and audio."""
|
||||
builder = DAGBuilder()
|
||||
video = builder.source(str(test_video))
|
||||
audio = builder.source(str(test_audio))
|
||||
muxed = builder.mux(video, audio)
|
||||
builder.set_output(muxed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
|
||||
|
||||
class TestAudioMixExecutor:
|
||||
"""Test AUDIO_MIX node executor."""
|
||||
|
||||
def test_audio_mix_simple(self, engine, cache_dir):
|
||||
"""Test simple audio mixing."""
|
||||
# Create two test audio files with different frequencies
|
||||
audio1_path = cache_dir / "audio1.mp3"
|
||||
audio2_path = cache_dir / "audio2.mp3"
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||
"-c:a", "libmp3lame",
|
||||
str(audio1_path)
|
||||
], capture_output=True, check=True)
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:duration=3",
|
||||
"-c:a", "libmp3lame",
|
||||
str(audio2_path)
|
||||
], capture_output=True, check=True)
|
||||
|
||||
builder = DAGBuilder()
|
||||
a1 = builder.source(str(audio1_path))
|
||||
a2 = builder.source(str(audio2_path))
|
||||
mixed = builder.audio_mix([a1, a2])
|
||||
builder.set_output(mixed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
|
||||
def test_audio_mix_with_gains(self, engine, cache_dir):
|
||||
"""Test audio mixing with custom gains."""
|
||||
audio1_path = cache_dir / "audio1.mp3"
|
||||
audio2_path = cache_dir / "audio2.mp3"
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||
"-c:a", "libmp3lame",
|
||||
str(audio1_path)
|
||||
], capture_output=True, check=True)
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:duration=3",
|
||||
"-c:a", "libmp3lame",
|
||||
str(audio2_path)
|
||||
], capture_output=True, check=True)
|
||||
|
||||
builder = DAGBuilder()
|
||||
a1 = builder.source(str(audio1_path))
|
||||
a2 = builder.source(str(audio2_path))
|
||||
mixed = builder.audio_mix([a1, a2], gains=[1.0, 0.3])
|
||||
builder.set_output(mixed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
|
||||
def test_audio_mix_three_inputs(self, engine, cache_dir):
|
||||
"""Test mixing three audio sources."""
|
||||
audio_paths = []
|
||||
for i, freq in enumerate([440, 660, 880]):
|
||||
path = cache_dir / f"audio{i}.mp3"
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", f"sine=frequency={freq}:duration=2",
|
||||
"-c:a", "libmp3lame",
|
||||
str(path)
|
||||
], capture_output=True, check=True)
|
||||
audio_paths.append(path)
|
||||
|
||||
builder = DAGBuilder()
|
||||
sources = [builder.source(str(p)) for p in audio_paths]
|
||||
mixed = builder.audio_mix(sources, gains=[1.0, 0.5, 0.3])
|
||||
builder.set_output(mixed)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
|
||||
|
||||
class TestCaching:
|
||||
"""Test engine caching behavior."""
|
||||
|
||||
def test_cache_reuse(self, engine, test_video):
|
||||
"""Test that cached results are reused."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
builder.set_output(source)
|
||||
dag = builder.build()
|
||||
|
||||
# First execution
|
||||
result1 = engine.execute(dag)
|
||||
assert result1.success
|
||||
assert result1.nodes_cached == 0
|
||||
assert result1.nodes_executed == 1
|
||||
|
||||
# Second execution should use cache
|
||||
result2 = engine.execute(dag)
|
||||
assert result2.success
|
||||
assert result2.nodes_cached == 1
|
||||
assert result2.nodes_executed == 0
|
||||
|
||||
def test_clear_cache(self, engine, test_video):
|
||||
"""Test clearing cache."""
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
builder.set_output(source)
|
||||
dag = builder.build()
|
||||
|
||||
engine.execute(dag)
|
||||
assert engine.cache.stats.total_entries == 1
|
||||
|
||||
engine.clear_cache()
|
||||
assert engine.cache.stats.total_entries == 0
|
||||
|
||||
|
||||
class TestProgressCallback:
|
||||
"""Test progress callback functionality."""
|
||||
|
||||
def test_progress_callback(self, engine, test_video):
|
||||
"""Test that progress callback is called."""
|
||||
progress_updates = []
|
||||
|
||||
def callback(progress):
|
||||
progress_updates.append((progress.node_id, progress.status))
|
||||
|
||||
engine.set_progress_callback(callback)
|
||||
|
||||
builder = DAGBuilder()
|
||||
source = builder.source(str(test_video))
|
||||
builder.set_output(source)
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert len(progress_updates) > 0
|
||||
# Should have pending, running, completed
|
||||
statuses = [p[1] for p in progress_updates]
|
||||
assert "pending" in statuses
|
||||
assert "completed" in statuses
|
||||
|
||||
|
||||
class TestFullWorkflow:
|
||||
"""Test complete workflow."""
|
||||
|
||||
def test_full_pipeline(self, engine, test_video, test_audio):
|
||||
"""Test complete video processing pipeline."""
|
||||
builder = DAGBuilder()
|
||||
|
||||
# Load sources
|
||||
video = builder.source(str(test_video))
|
||||
audio = builder.source(str(test_audio))
|
||||
|
||||
# Extract segment
|
||||
segment = builder.segment(video, duration=3.0)
|
||||
|
||||
# Resize
|
||||
resized = builder.resize(segment, width=640, height=480)
|
||||
|
||||
# Apply effects
|
||||
transformed = builder.transform(resized, effects={"saturation": 1.3})
|
||||
|
||||
# Mux with audio
|
||||
final = builder.mux(transformed, audio)
|
||||
builder.set_output(final)
|
||||
|
||||
dag = builder.build()
|
||||
|
||||
result = engine.execute(dag)
|
||||
|
||||
assert result.success
|
||||
assert result.output_path.exists()
|
||||
assert result.nodes_executed == 6 # source, source, segment, resize, transform, mux
|
||||
Reference in New Issue
Block a user