Initial release — OpenMontage: the first open-source agentic video production system
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
"""Phase 0 contract tests — infrastructure layer.
|
||||
|
||||
Tests config, schemas, checkpoints, pipeline manifests, tools, cost tracker,
|
||||
and media profiles. The intelligence layer (orchestrator, reviewer, checkpoint
|
||||
policy, handlers) has been replaced by instruction-driven architecture:
|
||||
pipeline manifests + stage director skills + meta skills.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from lib.config_model import OpenMontageConfig
|
||||
from lib.checkpoint import (
|
||||
CheckpointValidationError,
|
||||
STAGES,
|
||||
get_next_stage,
|
||||
read_checkpoint,
|
||||
write_checkpoint,
|
||||
)
|
||||
from lib.media_profiles import get_profile, ffmpeg_output_args, ALL_PROFILES
|
||||
from lib.pipeline_loader import (
|
||||
get_required_tools,
|
||||
get_stage_order,
|
||||
get_stage_skill,
|
||||
get_stage_review_focus,
|
||||
list_pipelines,
|
||||
load_pipeline,
|
||||
)
|
||||
from tools.base_tool import BaseTool, ToolResult, ToolTier, ToolStatus
|
||||
from tools.tool_registry import ToolRegistry
|
||||
from tools.cost_tracker import CostTracker, BudgetMode, BudgetExceededError, ApprovalRequiredError
|
||||
from schemas.artifacts import load_schema, validate_artifact, list_schemas
|
||||
|
||||
|
||||
def sample_artifact(name: str) -> dict:
|
||||
"""Return a minimal schema-valid artifact for tests."""
|
||||
if name == "research_brief":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"topic": "Test Topic",
|
||||
"research_date": "2026-03-27",
|
||||
"landscape": {
|
||||
"existing_content": [
|
||||
{"title": "Existing Video 1", "source": "youtube", "angle": "tutorial", "what_it_covers": "basics"},
|
||||
{"title": "Existing Video 2", "source": "blog", "angle": "deep dive", "what_it_covers": "advanced"},
|
||||
{"title": "Existing Video 3", "source": "youtube", "angle": "comparison", "what_it_covers": "alternatives"},
|
||||
],
|
||||
"saturated_angles": ["basic tutorial"],
|
||||
"underserved_gaps": ["misconceptions about topic"],
|
||||
},
|
||||
"data_points": [
|
||||
{"claim": "73% of users prefer X", "source_url": "https://example.com/study", "credibility": "primary_source"},
|
||||
{"claim": "Market grew 40% in 2025", "source_url": "https://example.com/report", "credibility": "secondary_source"},
|
||||
{"claim": "Most experts agree on Y", "source_url": "https://example.com/survey", "credibility": "primary_source"},
|
||||
],
|
||||
"audience_insights": {
|
||||
"common_questions": ["What is X?", "How does X work?", "Why is X important?"],
|
||||
"misconceptions": [{"myth": "X is slow", "reality": "X is fast"}],
|
||||
"knowledge_level": "Beginner to intermediate",
|
||||
},
|
||||
"angles_discovered": [
|
||||
{"name": "The Surprising Truth", "hook": "You think X is slow. It's not.", "type": "contrarian", "why_now": "New benchmark data", "grounded_in": ["data_point_1"]},
|
||||
{"name": "X From Scratch", "hook": "Build X in 5 minutes.", "type": "evergreen", "why_now": "Audience demand", "grounded_in": ["audience_q1"]},
|
||||
{"name": "Why X Matters Now", "hook": "X just changed everything.", "type": "trending", "why_now": "Recent announcement", "grounded_in": ["trending_1"]},
|
||||
],
|
||||
"sources": [
|
||||
{"url": "https://example.com/study", "title": "Study on X", "used_for": "data_points"},
|
||||
{"url": "https://example.com/report", "title": "Market Report", "used_for": "data_points"},
|
||||
{"url": "https://example.com/survey", "title": "Expert Survey", "used_for": "data_points"},
|
||||
{"url": "https://example.com/reddit", "title": "Reddit Discussion", "used_for": "audience_insights"},
|
||||
{"url": "https://example.com/blog", "title": "Tech Blog", "used_for": "landscape"},
|
||||
],
|
||||
}
|
||||
if name == "proposal_packet":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"concept_options": [
|
||||
{
|
||||
"id": "c1", "title": "The Surprising Truth About X", "hook": "You think X is slow.",
|
||||
"narrative_structure": "myth_busting", "visual_approach": "animated diagrams",
|
||||
"target_duration_seconds": 60, "why_this_works": "Strong misconception found in research",
|
||||
},
|
||||
{
|
||||
"id": "c2", "title": "X From Scratch", "hook": "Build X in 5 minutes.",
|
||||
"narrative_structure": "tutorial", "visual_approach": "code walkthrough",
|
||||
"target_duration_seconds": 90, "why_this_works": "High demand in audience questions",
|
||||
},
|
||||
{
|
||||
"id": "c3", "title": "Why X Matters Now", "hook": "X just changed everything.",
|
||||
"narrative_structure": "timeline", "visual_approach": "motion graphics",
|
||||
"target_duration_seconds": 75, "why_this_works": "Recent announcement creates timeliness",
|
||||
},
|
||||
],
|
||||
"selected_concept": {"concept_id": "c1", "rationale": "Strongest research backing"},
|
||||
"production_plan": {
|
||||
"pipeline": "animated-explainer",
|
||||
"stages": [
|
||||
{"stage": "script", "tools": [], "approach": "Write from research"},
|
||||
{"stage": "assets", "tools": [{"tool_name": "tts_selector", "role": "narration", "available": True}], "approach": "Generate assets"},
|
||||
],
|
||||
},
|
||||
"cost_estimate": {
|
||||
"total_estimated_usd": 0.52,
|
||||
"line_items": [{"tool": "elevenlabs_tts", "operation": "narration", "estimated_usd": 0.18}],
|
||||
"budget_verdict": "within_budget",
|
||||
},
|
||||
"approval": {"status": "approved"},
|
||||
}
|
||||
if name == "brief":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"title": "Test Brief",
|
||||
"hook": "Did you know?",
|
||||
"key_points": ["point 1"],
|
||||
"tone": "casual",
|
||||
"style": "clean-professional",
|
||||
"target_platform": "youtube",
|
||||
"target_duration_seconds": 60,
|
||||
}
|
||||
if name == "script":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"title": "Test Script",
|
||||
"total_duration_seconds": 60,
|
||||
"sections": [
|
||||
{
|
||||
"id": "s1",
|
||||
"text": "Hello world",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
if name == "scene_plan":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"scenes": [
|
||||
{
|
||||
"id": "scene-1",
|
||||
"type": "talking_head",
|
||||
"description": "Host on camera",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
if name == "asset_manifest":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-1",
|
||||
"type": "video",
|
||||
"path": "assets/clip.mp4",
|
||||
"source_tool": "ffmpeg",
|
||||
"scene_id": "scene-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
if name == "edit_decisions":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"cuts": [
|
||||
{
|
||||
"id": "cut-1",
|
||||
"source": "asset-1",
|
||||
"in_seconds": 0,
|
||||
"out_seconds": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
if name == "render_report":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"outputs": [
|
||||
{
|
||||
"path": "renders/output.mp4",
|
||||
"format": "mp4",
|
||||
"resolution": "1920x1080",
|
||||
"duration_seconds": 60,
|
||||
}
|
||||
],
|
||||
}
|
||||
if name == "publish_log":
|
||||
return {
|
||||
"version": "1.0",
|
||||
"entries": [
|
||||
{
|
||||
"platform": "youtube",
|
||||
"status": "draft",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
raise KeyError(f"Unknown artifact sample: {name}")
|
||||
|
||||
|
||||
# ---- Config ----
|
||||
|
||||
class TestConfig:
|
||||
def test_load_defaults(self):
|
||||
config = OpenMontageConfig()
|
||||
assert config.llm.provider == "anthropic"
|
||||
assert config.budget.mode.value == "warn"
|
||||
assert config.checkpoint.policy.value == "guided"
|
||||
|
||||
def test_load_from_yaml(self):
|
||||
config = OpenMontageConfig.load()
|
||||
assert config.budget.total_usd == 10.0
|
||||
|
||||
|
||||
# ---- Schemas ----
|
||||
|
||||
class TestSchemas:
|
||||
def test_all_schemas_loadable(self):
|
||||
names = list_schemas()
|
||||
assert len(names) >= 7
|
||||
for name in names:
|
||||
schema = load_schema(name)
|
||||
assert "$schema" in schema
|
||||
|
||||
def test_brief_validates(self):
|
||||
validate_artifact("brief", sample_artifact("brief"))
|
||||
|
||||
def test_brief_rejects_invalid(self):
|
||||
with pytest.raises(Exception):
|
||||
validate_artifact("brief", {"version": "1.0"})
|
||||
|
||||
|
||||
# ---- Checkpoint ----
|
||||
|
||||
class TestCheckpoint:
|
||||
def test_write_read_roundtrip(self, tmp_path):
|
||||
write_checkpoint(
|
||||
tmp_path, "test_project", "research", "completed",
|
||||
{"research_brief": sample_artifact("research_brief")},
|
||||
)
|
||||
cp = read_checkpoint(tmp_path, "test_project", "research")
|
||||
assert cp is not None
|
||||
assert cp["stage"] == "research"
|
||||
assert cp["status"] == "completed"
|
||||
assert cp["artifacts"]["research_brief"]["topic"] == "Test Topic"
|
||||
|
||||
def test_get_next_stage(self, tmp_path):
|
||||
assert get_next_stage(tmp_path, "proj") == "research"
|
||||
write_checkpoint(
|
||||
tmp_path,
|
||||
"proj",
|
||||
"research",
|
||||
"completed",
|
||||
{"research_brief": sample_artifact("research_brief")},
|
||||
)
|
||||
assert get_next_stage(tmp_path, "proj") == "proposal"
|
||||
|
||||
def test_invalid_stage_rejected(self, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_checkpoint(tmp_path, "proj", "invalid_stage", "completed", {})
|
||||
|
||||
def test_invalid_canonical_artifact_rejected(self, tmp_path):
|
||||
with pytest.raises(CheckpointValidationError):
|
||||
write_checkpoint(
|
||||
tmp_path,
|
||||
"proj",
|
||||
"research",
|
||||
"completed",
|
||||
{"research_brief": {"topic": "missing schema fields"}},
|
||||
)
|
||||
|
||||
def test_missing_canonical_artifact_rejected(self, tmp_path):
|
||||
with pytest.raises(CheckpointValidationError):
|
||||
write_checkpoint(tmp_path, "proj", "research", "completed", {})
|
||||
|
||||
def test_invalid_status_rejected(self, tmp_path):
|
||||
with pytest.raises(CheckpointValidationError):
|
||||
write_checkpoint(
|
||||
tmp_path,
|
||||
"proj",
|
||||
"research",
|
||||
"mystery",
|
||||
{"research_brief": sample_artifact("research_brief")},
|
||||
)
|
||||
|
||||
|
||||
# ---- Pipeline manifests ----
|
||||
|
||||
class TestPipelineManifests:
|
||||
def test_framework_smoke_manifest_loads(self):
|
||||
manifest = load_pipeline("framework-smoke")
|
||||
assert manifest["name"] == "framework-smoke"
|
||||
assert get_stage_order(manifest) == ["research", "script"]
|
||||
assert get_required_tools(manifest) == set()
|
||||
|
||||
def test_framework_smoke_manifest_listed(self):
|
||||
assert "framework-smoke" in list_pipelines()
|
||||
|
||||
|
||||
# ---- BaseTool ----
|
||||
|
||||
class DummyTool(BaseTool):
|
||||
name = "dummy"
|
||||
version = "0.1.0"
|
||||
tier = ToolTier.CORE
|
||||
capabilities = ["test"]
|
||||
dependencies = []
|
||||
|
||||
def execute(self, inputs):
|
||||
return ToolResult(success=True, data={"echo": inputs})
|
||||
|
||||
|
||||
class TestBaseTool:
|
||||
def test_get_info(self):
|
||||
tool = DummyTool()
|
||||
info = tool.get_info()
|
||||
assert info["name"] == "dummy"
|
||||
assert info["tier"] == "core"
|
||||
assert info["status"] == "available"
|
||||
|
||||
def test_execute(self):
|
||||
tool = DummyTool()
|
||||
result = tool.execute({"msg": "hello"})
|
||||
assert result.success
|
||||
|
||||
def test_unavailable_when_deps_missing(self):
|
||||
class MissingDepTool(BaseTool):
|
||||
name = "missing"
|
||||
dependencies = ["cmd:nonexistent_binary_xyz"]
|
||||
def execute(self, inputs):
|
||||
return ToolResult(success=True)
|
||||
|
||||
tool = MissingDepTool()
|
||||
assert tool.get_status() == ToolStatus.UNAVAILABLE
|
||||
|
||||
|
||||
# ---- ToolRegistry ----
|
||||
|
||||
class TestToolRegistry:
|
||||
def test_register_and_find(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(DummyTool())
|
||||
assert reg.get("dummy") is not None
|
||||
assert "dummy" in reg.list_all()
|
||||
assert len(reg.get_by_tier(ToolTier.CORE)) == 1
|
||||
assert len(reg.find_by_capability("test")) == 1
|
||||
|
||||
def test_support_envelope(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(DummyTool())
|
||||
envelope = reg.support_envelope()
|
||||
assert "dummy" in envelope
|
||||
assert envelope["dummy"]["status"] == "available"
|
||||
|
||||
def test_discovers_concrete_tools_from_package(self, tmp_path, monkeypatch):
|
||||
package_dir = tmp_path / "demo_tools"
|
||||
package_dir.mkdir()
|
||||
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package_dir / "demo_tool.py").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"from tools.base_tool import BaseTool, ToolResult, ToolTier",
|
||||
"",
|
||||
"class DiscoveredTool(BaseTool):",
|
||||
" name = 'discovered'",
|
||||
" tier = ToolTier.CORE",
|
||||
" capabilities = ['discover']",
|
||||
" dependencies = []",
|
||||
"",
|
||||
" def execute(self, inputs):",
|
||||
" return ToolResult(success=True, data=inputs)",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
importlib.invalidate_caches()
|
||||
|
||||
reg = ToolRegistry()
|
||||
discovered = reg.discover("demo_tools")
|
||||
|
||||
assert discovered == ["discovered"]
|
||||
assert reg.get("discovered") is not None
|
||||
assert reg.find_by_capability("discover")[0].name == "discovered"
|
||||
|
||||
|
||||
# ---- CostTracker ----
|
||||
|
||||
class TestCostTracker:
|
||||
def test_estimate_reserve_reconcile(self):
|
||||
tracker = CostTracker(budget_total_usd=10.0, mode=BudgetMode.OBSERVE)
|
||||
entry_id = tracker.estimate("image_selector", "generate", 0.05)
|
||||
tracker.reserve(entry_id)
|
||||
assert tracker.budget_reserved_usd == 0.05
|
||||
tracker.reconcile(entry_id, 0.04, success=True)
|
||||
assert tracker.budget_spent_usd == 0.04
|
||||
assert tracker.budget_reserved_usd == 0.0
|
||||
|
||||
def test_cap_mode_blocks_overspend(self):
|
||||
tracker = CostTracker(
|
||||
budget_total_usd=1.0,
|
||||
mode=BudgetMode.CAP,
|
||||
single_action_approval_usd=10.0, # raise threshold so budget check triggers
|
||||
)
|
||||
tracker.approve_tool("expensive")
|
||||
eid = tracker.estimate("expensive", "op", 5.0)
|
||||
with pytest.raises(BudgetExceededError):
|
||||
tracker.reserve(eid)
|
||||
|
||||
def test_persistence(self, tmp_path):
|
||||
log_path = tmp_path / "cost_log.json"
|
||||
t1 = CostTracker(budget_total_usd=10.0, mode=BudgetMode.OBSERVE, cost_log_path=log_path)
|
||||
eid = t1.estimate("tool", "op", 0.10)
|
||||
t1.reserve(eid)
|
||||
t1.reconcile(eid, 0.08)
|
||||
|
||||
t2 = CostTracker(cost_log_path=log_path)
|
||||
assert t2.budget_spent_usd == 0.08
|
||||
|
||||
|
||||
# ---- Pipeline Instruction Architecture ----
|
||||
|
||||
class TestPipelineInstructionArchitecture:
|
||||
"""Verify that the instruction-driven architecture is in place:
|
||||
manifests reference skills, not Python handlers."""
|
||||
|
||||
def test_animated_explainer_stages_have_skills(self):
|
||||
try:
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
except FileNotFoundError:
|
||||
pytest.skip("animated-explainer manifest not yet created")
|
||||
for stage in manifest["stages"]:
|
||||
assert "skill" in stage, f"Stage {stage['name']} missing skill field"
|
||||
|
||||
def test_stage_skill_lookup(self):
|
||||
manifest = load_pipeline("framework-smoke")
|
||||
# framework-smoke may not have skills yet — just verify the function works
|
||||
result = get_stage_skill(manifest, "idea")
|
||||
assert result is None or isinstance(result, str)
|
||||
|
||||
def test_stage_review_focus_lookup(self):
|
||||
manifest = load_pipeline("framework-smoke")
|
||||
result = get_stage_review_focus(manifest, "idea")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# ---- Agent context files ----
|
||||
|
||||
class TestAgentContextFiles:
|
||||
def test_agent_guide_contains_canonical_sections(self):
|
||||
contents = (PROJECT_ROOT / "AGENT_GUIDE.md").read_text(encoding="utf-8")
|
||||
for header in (
|
||||
"## Orchestrator",
|
||||
"## Stage Agents",
|
||||
"## Reviewer Protocol",
|
||||
"## Communication Protocol",
|
||||
"## Human Checkpoint Protocol",
|
||||
):
|
||||
assert header in contents
|
||||
|
||||
def test_platform_wrappers_reference_agent_guide(self):
|
||||
for path in ("CLAUDE.md", "CODEX.md", "CURSOR.md", "COPILOT.md", "AGENTS.md"):
|
||||
contents = (PROJECT_ROOT / path).read_text(encoding="utf-8")
|
||||
assert "AGENT_GUIDE.md" in contents
|
||||
|
||||
|
||||
# ---- Media Profiles ----
|
||||
|
||||
class TestMediaProfiles:
|
||||
def test_all_profiles_exist(self):
|
||||
assert len(ALL_PROFILES) >= 9
|
||||
|
||||
def test_get_profile(self):
|
||||
p = get_profile("youtube_landscape")
|
||||
assert p.width == 1920
|
||||
assert p.height == 1080
|
||||
|
||||
def test_ffmpeg_args(self):
|
||||
args = ffmpeg_output_args(get_profile("tiktok"))
|
||||
assert "-c:v" in args
|
||||
assert "1080" in args[-1]
|
||||
|
||||
def test_unknown_profile_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
get_profile("nonexistent")
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Phase 1 contract tests — Core Talking-Head Pipeline tools."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tools.base_tool import BaseTool, ToolResult, ToolTier, ToolStatus, DependencyError
|
||||
from tools.tool_registry import ToolRegistry
|
||||
from lib.pipeline_loader import load_pipeline, get_stage_order, get_required_tools, list_pipelines
|
||||
|
||||
|
||||
# ---- Tool imports ----
|
||||
|
||||
from tools.analysis.transcriber import Transcriber
|
||||
from tools.video.video_trimmer import VideoTrimmer
|
||||
from tools.subtitle.subtitle_gen import SubtitleGen
|
||||
from tools.analysis.frame_sampler import FrameSampler
|
||||
from tools.audio.audio_mixer import AudioMixer
|
||||
from tools.video.video_compose import VideoCompose
|
||||
|
||||
|
||||
# ---- Contract: every tool inherits BaseTool and has required fields ----
|
||||
|
||||
PHASE1_TOOLS = [
|
||||
Transcriber,
|
||||
VideoTrimmer,
|
||||
SubtitleGen,
|
||||
FrameSampler,
|
||||
AudioMixer,
|
||||
VideoCompose,
|
||||
]
|
||||
|
||||
|
||||
class TestPhase1ToolContracts:
|
||||
"""Verify all Phase 1 tools satisfy the ToolContract."""
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_inherits_base_tool(self, tool_cls):
|
||||
assert issubclass(tool_cls, BaseTool)
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_has_required_identity(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
assert tool.name, f"{tool_cls.__name__} must have a non-empty name"
|
||||
assert tool.version, f"{tool_cls.__name__} must have a version"
|
||||
assert tool.tier in ToolTier
|
||||
assert len(tool.capabilities) > 0, f"{tool_cls.__name__} must declare capabilities"
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_get_info_returns_valid_dict(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
info = tool.get_info()
|
||||
assert isinstance(info, dict)
|
||||
assert info["name"] == tool.name
|
||||
assert info["tier"] in [t.value for t in ToolTier]
|
||||
assert info["status"] in ["available", "unavailable", "degraded"]
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_has_input_schema(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
assert isinstance(tool.input_schema, dict)
|
||||
assert "properties" in tool.input_schema or "type" in tool.input_schema
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_execute_is_implemented(self, tool_cls):
|
||||
"""Verify execute() is not the abstract stub."""
|
||||
tool = tool_cls()
|
||||
# Should not raise TypeError — it's implemented
|
||||
assert callable(tool.execute)
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE1_TOOLS)
|
||||
def test_dry_run_returns_dict(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
result = tool.dry_run({})
|
||||
assert isinstance(result, dict)
|
||||
assert "tool" in result
|
||||
assert result["tool"] == tool.name
|
||||
|
||||
|
||||
# ---- Contract: tools report correct status based on dependencies ----
|
||||
|
||||
class TestPhase1ToolStatus:
|
||||
def test_subtitle_gen_always_available(self):
|
||||
"""SubtitleGen has no external dependencies — always available."""
|
||||
tool = SubtitleGen()
|
||||
assert tool.get_status() == ToolStatus.AVAILABLE
|
||||
|
||||
def test_transcriber_reports_status_correctly(self):
|
||||
"""Transcriber should report unavailable if faster_whisper not installed."""
|
||||
tool = Transcriber()
|
||||
status = tool.get_status()
|
||||
assert status in (ToolStatus.AVAILABLE, ToolStatus.UNAVAILABLE)
|
||||
|
||||
def test_ffmpeg_tools_report_status(self):
|
||||
"""FFmpeg-dependent tools should report based on ffmpeg availability."""
|
||||
for cls in [VideoTrimmer, FrameSampler, AudioMixer, VideoCompose]:
|
||||
tool = cls()
|
||||
status = tool.get_status()
|
||||
assert status in (ToolStatus.AVAILABLE, ToolStatus.UNAVAILABLE)
|
||||
|
||||
|
||||
# ---- Contract: tool names are unique ----
|
||||
|
||||
class TestPhase1ToolNames:
|
||||
def test_unique_names(self):
|
||||
names = [cls().name for cls in PHASE1_TOOLS]
|
||||
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
|
||||
|
||||
def test_expected_names(self):
|
||||
names = {cls().name for cls in PHASE1_TOOLS}
|
||||
expected = {"transcriber", "video_trimmer", "subtitle_gen", "frame_sampler", "audio_mixer", "video_compose"}
|
||||
assert names == expected
|
||||
|
||||
|
||||
# ---- Contract: tools are discoverable via registry ----
|
||||
|
||||
class TestPhase1ToolDiscovery:
|
||||
def test_all_phase1_tools_discoverable(self):
|
||||
"""Registry.discover() should find all Phase 1 tools."""
|
||||
reg = ToolRegistry()
|
||||
discovered = reg.discover("tools")
|
||||
for cls in PHASE1_TOOLS:
|
||||
name = cls().name
|
||||
assert name in discovered or reg.get(name) is not None, (
|
||||
f"Tool {name!r} not discovered by registry"
|
||||
)
|
||||
|
||||
def test_phase1_tools_are_core_tier(self):
|
||||
"""All Phase 1 tools should be in the CORE tier."""
|
||||
for cls in PHASE1_TOOLS:
|
||||
tool = cls()
|
||||
assert tool.tier == ToolTier.CORE, f"{tool.name} should be CORE tier"
|
||||
|
||||
|
||||
# ---- Contract: SubtitleGen produces valid output without FFmpeg ----
|
||||
|
||||
class TestSubtitleGenUnit:
|
||||
def test_srt_generation(self):
|
||||
segments = [
|
||||
{
|
||||
"text": "Hello world",
|
||||
"start": 0.0,
|
||||
"end": 1.5,
|
||||
"words": [
|
||||
{"word": "Hello", "start": 0.0, "end": 0.5},
|
||||
{"word": "world", "start": 0.6, "end": 1.5},
|
||||
],
|
||||
},
|
||||
{
|
||||
"text": "This is a test",
|
||||
"start": 2.0,
|
||||
"end": 4.0,
|
||||
"words": [
|
||||
{"word": "This", "start": 2.0, "end": 2.3},
|
||||
{"word": "is", "start": 2.4, "end": 2.5},
|
||||
{"word": "a", "start": 2.6, "end": 2.7},
|
||||
{"word": "test", "start": 2.8, "end": 4.0},
|
||||
],
|
||||
},
|
||||
]
|
||||
tool = SubtitleGen()
|
||||
result = tool.execute({
|
||||
"segments": segments,
|
||||
"format": "srt",
|
||||
"output_path": "test_output.srt",
|
||||
})
|
||||
assert result.success
|
||||
assert len(result.artifacts) == 1
|
||||
|
||||
content = Path(result.artifacts[0]).read_text()
|
||||
assert "-->" in content
|
||||
assert "Hello world" in content
|
||||
# Cleanup
|
||||
Path(result.artifacts[0]).unlink(missing_ok=True)
|
||||
|
||||
def test_vtt_generation(self):
|
||||
segments = [
|
||||
{
|
||||
"text": "Test cue",
|
||||
"start": 1.0,
|
||||
"end": 3.0,
|
||||
"words": [
|
||||
{"word": "Test", "start": 1.0, "end": 1.5},
|
||||
{"word": "cue", "start": 1.6, "end": 3.0},
|
||||
],
|
||||
},
|
||||
]
|
||||
tool = SubtitleGen()
|
||||
result = tool.execute({
|
||||
"segments": segments,
|
||||
"format": "vtt",
|
||||
"output_path": "test_output.vtt",
|
||||
})
|
||||
assert result.success
|
||||
content = Path(result.artifacts[0]).read_text()
|
||||
assert content.startswith("WEBVTT")
|
||||
Path(result.artifacts[0]).unlink(missing_ok=True)
|
||||
|
||||
def test_json_generation(self):
|
||||
segments = [
|
||||
{
|
||||
"text": "JSON test",
|
||||
"start": 0.0,
|
||||
"end": 2.0,
|
||||
"words": [
|
||||
{"word": "JSON", "start": 0.0, "end": 0.8},
|
||||
{"word": "test", "start": 0.9, "end": 2.0},
|
||||
],
|
||||
},
|
||||
]
|
||||
tool = SubtitleGen()
|
||||
result = tool.execute({
|
||||
"segments": segments,
|
||||
"format": "json",
|
||||
"output_path": "test_output.caption.json",
|
||||
})
|
||||
assert result.success
|
||||
data = json.loads(Path(result.artifacts[0]).read_text())
|
||||
assert "cues" in data
|
||||
assert len(data["cues"]) >= 1
|
||||
Path(result.artifacts[0]).unlink(missing_ok=True)
|
||||
|
||||
def test_word_grouping_respects_max_words(self):
|
||||
words = [{"word": f"w{i}", "start": i * 0.5, "end": i * 0.5 + 0.4} for i in range(20)]
|
||||
segments = [{"text": " ".join(w["word"] for w in words), "start": 0.0, "end": 10.0, "words": words}]
|
||||
|
||||
tool = SubtitleGen()
|
||||
result = tool.execute({
|
||||
"segments": segments,
|
||||
"format": "json",
|
||||
"max_words_per_cue": 4,
|
||||
"output_path": "test_grouping.caption.json",
|
||||
})
|
||||
assert result.success
|
||||
data = json.loads(Path(result.artifacts[0]).read_text())
|
||||
for cue in data["cues"]:
|
||||
assert len(cue["words"]) <= 4
|
||||
Path(result.artifacts[0]).unlink(missing_ok=True)
|
||||
|
||||
def test_segment_fallback_without_words(self):
|
||||
"""Segments without word-level timestamps use segment-level timing."""
|
||||
segments = [
|
||||
{"text": "No word data", "start": 0.0, "end": 2.0},
|
||||
]
|
||||
tool = SubtitleGen()
|
||||
result = tool.execute({
|
||||
"segments": segments,
|
||||
"format": "srt",
|
||||
"output_path": "test_fallback.srt",
|
||||
})
|
||||
assert result.success
|
||||
content = Path(result.artifacts[0]).read_text()
|
||||
assert "No word data" in content
|
||||
Path(result.artifacts[0]).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---- Contract: missing input file returns error, not crash ----
|
||||
|
||||
class TestPhase1ErrorHandling:
|
||||
def test_transcriber_missing_file(self):
|
||||
tool = Transcriber()
|
||||
result = tool.execute({"input_path": "/nonexistent/file.mp4"})
|
||||
assert not result.success
|
||||
assert "not found" in result.error.lower() or "not installed" in result.error.lower()
|
||||
|
||||
def test_video_trimmer_missing_file(self):
|
||||
tool = VideoTrimmer()
|
||||
result = tool.execute({
|
||||
"operation": "cut",
|
||||
"input_path": "/nonexistent/file.mp4",
|
||||
})
|
||||
assert not result.success
|
||||
|
||||
def test_frame_sampler_missing_file(self):
|
||||
tool = FrameSampler()
|
||||
result = tool.execute({
|
||||
"input_path": "/nonexistent/file.mp4",
|
||||
"strategy": "interval",
|
||||
})
|
||||
assert not result.success
|
||||
|
||||
def test_audio_mixer_missing_tracks(self):
|
||||
tool = AudioMixer()
|
||||
result = tool.execute({"operation": "mix", "tracks": []})
|
||||
assert not result.success
|
||||
|
||||
def test_video_compose_missing_decisions(self):
|
||||
tool = VideoCompose()
|
||||
result = tool.execute({"operation": "compose"})
|
||||
assert not result.success
|
||||
|
||||
|
||||
# ---- Contract: talking-head pipeline manifest ----
|
||||
|
||||
class TestTalkingHeadManifest:
|
||||
def test_manifest_loads(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
assert manifest["name"] == "talking-head"
|
||||
assert manifest["category"] == "talking_head"
|
||||
|
||||
def test_manifest_has_all_stages(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
stages = get_stage_order(manifest)
|
||||
assert stages == ["idea", "script", "scene_plan", "assets", "edit", "compose", "publish"]
|
||||
|
||||
def test_manifest_references_phase1_tools(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
tools = get_required_tools(manifest)
|
||||
phase1_tools = {"transcriber", "video_trimmer", "subtitle_gen", "frame_sampler", "audio_mixer", "video_compose"}
|
||||
# At least some Phase 1 tools should be referenced
|
||||
assert len(tools & phase1_tools) > 0
|
||||
|
||||
def test_manifest_listed(self):
|
||||
assert "talking-head" in list_pipelines()
|
||||
|
||||
def test_manifest_has_required_skills(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
skills = manifest.get("required_skills", [])
|
||||
# Instruction-driven architecture: skills are stage director + meta skills
|
||||
assert any("talking-head" in s for s in skills)
|
||||
assert any("reviewer" in s for s in skills)
|
||||
assert any("checkpoint-protocol" in s for s in skills)
|
||||
|
||||
def test_idea_and_publish_require_approval(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
for stage in manifest["stages"]:
|
||||
if stage["name"] in ("idea", "publish"):
|
||||
assert stage.get("human_approval_default") is True
|
||||
|
||||
|
||||
# ---- Contract: skill files exist ----
|
||||
|
||||
class TestPhase1Skills:
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"skills/core/ffmpeg.md",
|
||||
"skills/core/whisperx.md",
|
||||
"skills/core/subtitle-sync.md",
|
||||
"skills/creative/video-editing.md",
|
||||
"skills/creative/enhancement-strategy.md",
|
||||
])
|
||||
def test_skill_file_exists(self, skill_path):
|
||||
full_path = PROJECT_ROOT / skill_path
|
||||
assert full_path.exists(), f"Skill file missing: {skill_path}"
|
||||
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"skills/core/ffmpeg.md",
|
||||
"skills/core/whisperx.md",
|
||||
"skills/core/subtitle-sync.md",
|
||||
"skills/creative/video-editing.md",
|
||||
"skills/creative/enhancement-strategy.md",
|
||||
])
|
||||
def test_skill_has_content(self, skill_path):
|
||||
full_path = PROJECT_ROOT / skill_path
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
assert len(content) > 100, f"Skill file too short: {skill_path}"
|
||||
assert "## When to Use" in content, f"Skill missing 'When to Use' section: {skill_path}"
|
||||
assert "## Quality Checklist" in content, f"Skill missing 'Quality Checklist' section: {skill_path}"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Phase 1 golden scenario test — validates the talking-head pipeline
|
||||
manifest and skill architecture are in place.
|
||||
|
||||
The old test ran the Python orchestrator pipeline end-to-end. That layer
|
||||
has been removed in favor of instruction-driven architecture: the agent
|
||||
reads pipeline manifests + stage director skills and drives the pipeline
|
||||
itself. These tests verify the infrastructure is correctly wired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from lib.checkpoint import STAGES
|
||||
from lib.pipeline_loader import (
|
||||
load_pipeline,
|
||||
get_stage_order,
|
||||
get_stage_skill,
|
||||
get_stage_review_focus,
|
||||
list_pipelines,
|
||||
)
|
||||
from schemas.artifacts import load_schema, validate_artifact, list_schemas
|
||||
|
||||
|
||||
class TestTalkingHeadManifest:
|
||||
"""Verify the talking-head pipeline manifest is well-formed."""
|
||||
|
||||
def test_manifest_loads(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
assert manifest["name"] == "talking-head"
|
||||
|
||||
def test_all_stages_present(self):
|
||||
manifest = load_pipeline("talking-head")
|
||||
stage_names = get_stage_order(manifest)
|
||||
expected = ["idea", "script", "scene_plan", "assets", "edit", "compose", "publish"]
|
||||
assert stage_names == expected
|
||||
|
||||
def test_manifest_listed(self):
|
||||
assert "talking-head" in list_pipelines()
|
||||
|
||||
|
||||
class TestGoldenScenarioArtifacts:
|
||||
"""Validate golden scenario artifact samples against schemas."""
|
||||
|
||||
GOLDEN_PATH = PROJECT_ROOT / "eval" / "golden_scenarios" / "talking_head_basic.json"
|
||||
|
||||
@pytest.fixture
|
||||
def golden(self):
|
||||
if not self.GOLDEN_PATH.exists():
|
||||
pytest.skip("Golden scenario file not found")
|
||||
return json.loads(self.GOLDEN_PATH.read_text())
|
||||
|
||||
def test_golden_file_structure(self, golden):
|
||||
assert "inputs" in golden
|
||||
assert "expected_artifacts" in golden
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Phase 2 side-by-side comparison: Phase 1 vs Phase 2 enhanced output.
|
||||
|
||||
Runs the talking-head compose handler twice — once with enhance=False
|
||||
(Phase 1 baseline) and once with enhance=True (Phase 2 enhanced) —
|
||||
then asserts both outputs exist and reports file size / duration for
|
||||
manual comparison.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
FOOTAGE = Path(
|
||||
r"C:\Users\ishan\Documents\SocialMedia\IshanAIVideos\pipeline\2026-03-08\seg1_intro.mp4"
|
||||
)
|
||||
|
||||
|
||||
def _has_ffmpeg() -> bool:
|
||||
import shutil
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
def _get_duration(path: str) -> float:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "json", path],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
return float(data.get("format", {}).get("duration", 0))
|
||||
|
||||
|
||||
def _get_file_size_mb(path: str) -> float:
|
||||
return Path(path).stat().st_size / (1024 * 1024)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def comparison_outputs(tmp_path_factory):
|
||||
"""Run compose handler twice: baseline and enhanced."""
|
||||
if not FOOTAGE.exists():
|
||||
pytest.skip("Test footage not available")
|
||||
if not _has_ffmpeg():
|
||||
pytest.skip("FFmpeg not available")
|
||||
|
||||
from tools.video.video_compose import VideoCompose
|
||||
from tools.enhancement.face_enhance import FaceEnhance
|
||||
from tools.enhancement.color_grade import ColorGrade
|
||||
from tools.audio.audio_enhance import AudioEnhance
|
||||
|
||||
tmp = tmp_path_factory.mktemp("comparison")
|
||||
results = {}
|
||||
|
||||
# Phase 1 baseline: just encode
|
||||
composer = VideoCompose()
|
||||
baseline_path = str(tmp / "phase1_baseline.mp4")
|
||||
r = composer.execute({
|
||||
"operation": "encode",
|
||||
"input_path": str(FOOTAGE),
|
||||
"output_path": baseline_path,
|
||||
"codec": "libx264",
|
||||
"crf": 20,
|
||||
})
|
||||
assert r.success, f"Baseline encode failed: {r.error}"
|
||||
results["baseline"] = baseline_path
|
||||
|
||||
# Phase 2 enhanced: face -> color -> audio
|
||||
current = str(FOOTAGE)
|
||||
enhancements = []
|
||||
|
||||
face = FaceEnhance()
|
||||
face_out = str(tmp / "step_face.mp4")
|
||||
r = face.execute({
|
||||
"input_path": current,
|
||||
"output_path": face_out,
|
||||
"presets": ["talking_head_standard"],
|
||||
})
|
||||
if r.success:
|
||||
current = r.data["output"]
|
||||
enhancements.append("face_enhance:talking_head_standard")
|
||||
|
||||
color = ColorGrade()
|
||||
color_out = str(tmp / "step_color.mp4")
|
||||
r = color.execute({
|
||||
"input_path": current,
|
||||
"output_path": color_out,
|
||||
"profile": "cinematic_warm",
|
||||
"intensity": 0.85,
|
||||
})
|
||||
if r.success:
|
||||
current = r.data["output"]
|
||||
enhancements.append("color_grade:cinematic_warm@0.85")
|
||||
|
||||
audio = AudioEnhance()
|
||||
audio_out = str(tmp / "step_audio.mp4")
|
||||
r = audio.execute({
|
||||
"input_path": current,
|
||||
"output_path": audio_out,
|
||||
"preset": "clean_speech",
|
||||
})
|
||||
if r.success:
|
||||
current = r.data["output"]
|
||||
enhancements.append("audio_enhance:clean_speech")
|
||||
|
||||
# Final encode
|
||||
enhanced_path = str(tmp / "phase2_enhanced.mp4")
|
||||
r = composer.execute({
|
||||
"operation": "encode",
|
||||
"input_path": current,
|
||||
"output_path": enhanced_path,
|
||||
"codec": "libx264",
|
||||
"crf": 20,
|
||||
})
|
||||
assert r.success, f"Enhanced encode failed: {r.error}"
|
||||
results["enhanced"] = enhanced_path
|
||||
results["enhancements"] = enhancements
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class TestPhase2Comparison:
|
||||
def test_baseline_exists(self, comparison_outputs):
|
||||
assert Path(comparison_outputs["baseline"]).exists()
|
||||
|
||||
def test_enhanced_exists(self, comparison_outputs):
|
||||
assert Path(comparison_outputs["enhanced"]).exists()
|
||||
|
||||
def test_both_have_valid_duration(self, comparison_outputs):
|
||||
base_dur = _get_duration(comparison_outputs["baseline"])
|
||||
enh_dur = _get_duration(comparison_outputs["enhanced"])
|
||||
assert base_dur > 0
|
||||
assert enh_dur > 0
|
||||
# Durations should be within 1 second of each other
|
||||
assert abs(base_dur - enh_dur) < 1.0, (
|
||||
f"Duration mismatch: baseline={base_dur:.1f}s, enhanced={enh_dur:.1f}s"
|
||||
)
|
||||
|
||||
def test_enhancements_were_applied(self, comparison_outputs):
|
||||
enhancements = comparison_outputs["enhancements"]
|
||||
assert len(enhancements) > 0, "No enhancements were applied"
|
||||
|
||||
def test_report(self, comparison_outputs):
|
||||
"""Print comparison report for manual review."""
|
||||
baseline = comparison_outputs["baseline"]
|
||||
enhanced = comparison_outputs["enhanced"]
|
||||
|
||||
base_size = _get_file_size_mb(baseline)
|
||||
enh_size = _get_file_size_mb(enhanced)
|
||||
base_dur = _get_duration(baseline)
|
||||
enh_dur = _get_duration(enhanced)
|
||||
enhancements = comparison_outputs["enhancements"]
|
||||
|
||||
report = (
|
||||
f"\n{'='*60}\n"
|
||||
f" Phase 1 vs Phase 2 Comparison\n"
|
||||
f"{'='*60}\n"
|
||||
f" Baseline: {base_size:.2f} MB, {base_dur:.1f}s\n"
|
||||
f" Enhanced: {enh_size:.2f} MB, {enh_dur:.1f}s\n"
|
||||
f" Enhancements: {', '.join(enhancements)}\n"
|
||||
f" Baseline path: {baseline}\n"
|
||||
f" Enhanced path: {enhanced}\n"
|
||||
f"{'='*60}\n"
|
||||
)
|
||||
print(report)
|
||||
# Always passes — this test is for reporting
|
||||
assert True
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Phase 2 contract tests — Enhancement Layer tools."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tools.base_tool import BaseTool, ToolResult, ToolTier, ToolStatus
|
||||
from tools.tool_registry import ToolRegistry
|
||||
|
||||
from tools.enhancement.face_enhance import FaceEnhance, PRESETS as FACE_PRESETS
|
||||
from tools.analysis.scene_detect import SceneDetect
|
||||
from tools.enhancement.color_grade import ColorGrade, PROFILES as COLOR_PROFILES
|
||||
from tools.audio.audio_enhance import AudioEnhance, PRESETS as AUDIO_PRESETS
|
||||
from tools.graphics.image_selector import ImageSelector
|
||||
from tools.graphics.code_snippet import CodeSnippet, THEMES as CODE_THEMES
|
||||
from tools.graphics.diagram_gen import DiagramGen
|
||||
|
||||
|
||||
PHASE2_TOOLS = [
|
||||
FaceEnhance,
|
||||
SceneDetect,
|
||||
ColorGrade,
|
||||
AudioEnhance,
|
||||
ImageSelector,
|
||||
CodeSnippet,
|
||||
DiagramGen,
|
||||
]
|
||||
|
||||
|
||||
# ---- Contract: every tool inherits BaseTool and has required fields ----
|
||||
|
||||
class TestPhase2ToolContracts:
|
||||
@pytest.mark.parametrize("tool_cls", PHASE2_TOOLS)
|
||||
def test_inherits_base_tool(self, tool_cls):
|
||||
assert issubclass(tool_cls, BaseTool)
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE2_TOOLS)
|
||||
def test_has_required_identity(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
assert tool.name
|
||||
assert tool.version
|
||||
assert tool.tier in ToolTier
|
||||
assert len(tool.capabilities) > 0
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE2_TOOLS)
|
||||
def test_get_info_returns_valid_dict(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
info = tool.get_info()
|
||||
assert isinstance(info, dict)
|
||||
assert info["name"] == tool.name
|
||||
assert info["status"] in ["available", "unavailable", "degraded"]
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE2_TOOLS)
|
||||
def test_execute_is_implemented(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
assert callable(tool.execute)
|
||||
|
||||
@pytest.mark.parametrize("tool_cls", PHASE2_TOOLS)
|
||||
def test_dry_run_returns_dict(self, tool_cls):
|
||||
tool = tool_cls()
|
||||
result = tool.dry_run({})
|
||||
assert isinstance(result, dict)
|
||||
assert result["tool"] == tool.name
|
||||
|
||||
|
||||
# ---- Contract: unique names, all CORE tier ----
|
||||
|
||||
class TestPhase2ToolNames:
|
||||
def test_unique_names(self):
|
||||
names = [cls().name for cls in PHASE2_TOOLS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_expected_names(self):
|
||||
names = {cls().name for cls in PHASE2_TOOLS}
|
||||
expected = {
|
||||
"face_enhance", "scene_detect", "color_grade", "audio_enhance",
|
||||
"image_selector", "code_snippet", "diagram_gen",
|
||||
}
|
||||
assert names == expected
|
||||
|
||||
def test_expected_tiers(self):
|
||||
for cls in PHASE2_TOOLS:
|
||||
tool = cls()
|
||||
if tool.name == "image_selector":
|
||||
assert tool.tier == ToolTier.GENERATE
|
||||
else:
|
||||
assert tool.tier == ToolTier.CORE, f"{tool.name} should be CORE"
|
||||
|
||||
|
||||
# ---- Contract: discoverable via registry ----
|
||||
|
||||
class TestPhase2ToolDiscovery:
|
||||
def test_all_phase2_tools_discoverable(self):
|
||||
reg = ToolRegistry()
|
||||
reg.discover("tools")
|
||||
for cls in PHASE2_TOOLS:
|
||||
name = cls().name
|
||||
assert reg.get(name) is not None, f"{name} not discovered"
|
||||
|
||||
|
||||
# ---- Contract: presets/profiles are well-formed ----
|
||||
|
||||
class TestPresets:
|
||||
def test_face_presets_have_vf(self):
|
||||
for name, preset in FACE_PRESETS.items():
|
||||
assert "vf" in preset, f"Face preset {name} missing 'vf'"
|
||||
assert "description" in preset
|
||||
|
||||
def test_color_profiles_have_vf(self):
|
||||
for name, profile in COLOR_PROFILES.items():
|
||||
assert "vf" in profile, f"Color profile {name} missing 'vf'"
|
||||
assert "description" in profile
|
||||
|
||||
def test_audio_presets_have_af(self):
|
||||
for name, preset in AUDIO_PRESETS.items():
|
||||
assert "af" in preset, f"Audio preset {name} missing 'af'"
|
||||
assert "description" in preset
|
||||
|
||||
def test_code_themes_have_required_fields(self):
|
||||
for name, theme in CODE_THEMES.items():
|
||||
assert "pygments_style" in theme
|
||||
assert "bg_color" in theme
|
||||
assert "text_color" in theme
|
||||
|
||||
def test_face_list_presets(self):
|
||||
presets = FaceEnhance.list_presets()
|
||||
assert len(presets) >= 8
|
||||
assert "talking_head_standard" in presets
|
||||
|
||||
def test_color_list_profiles(self):
|
||||
profiles = ColorGrade.list_profiles()
|
||||
assert len(profiles) >= 7
|
||||
assert "cinematic_warm" in profiles
|
||||
|
||||
def test_audio_list_presets(self):
|
||||
presets = AudioEnhance.list_presets()
|
||||
assert len(presets) >= 5
|
||||
assert "clean_speech" in presets
|
||||
|
||||
|
||||
# ---- Contract: error handling for missing inputs ----
|
||||
|
||||
class TestPhase2ErrorHandling:
|
||||
def test_face_enhance_missing_file(self):
|
||||
tool = FaceEnhance()
|
||||
r = tool.execute({"input_path": "/nonexistent.mp4"})
|
||||
assert not r.success
|
||||
|
||||
def test_scene_detect_missing_file(self):
|
||||
tool = SceneDetect()
|
||||
r = tool.execute({"input_path": "/nonexistent.mp4"})
|
||||
assert not r.success
|
||||
|
||||
def test_color_grade_missing_file(self):
|
||||
tool = ColorGrade()
|
||||
r = tool.execute({"input_path": "/nonexistent.mp4"})
|
||||
assert not r.success
|
||||
|
||||
def test_audio_enhance_missing_file(self):
|
||||
tool = AudioEnhance()
|
||||
r = tool.execute({"input_path": "/nonexistent.mp4"})
|
||||
assert not r.success
|
||||
|
||||
def test_image_selector_no_provider(self):
|
||||
tool = ImageSelector()
|
||||
# Will fail if no API key or local model
|
||||
r = tool.execute({"prompt": "test"})
|
||||
# Either succeeds (provider available) or fails gracefully
|
||||
assert isinstance(r, ToolResult)
|
||||
|
||||
def test_diagram_gen_empty_boxes(self):
|
||||
tool = DiagramGen()
|
||||
if tool.get_status() == ToolStatus.AVAILABLE:
|
||||
r = tool.execute({"diagram_type": "boxes", "boxes": []})
|
||||
assert isinstance(r, ToolResult)
|
||||
|
||||
|
||||
# ---- Contract: code_snippet renders valid images ----
|
||||
|
||||
class TestCodeSnippetUnit:
|
||||
@pytest.fixture
|
||||
def has_deps(self):
|
||||
tool = CodeSnippet()
|
||||
if tool.get_status() != ToolStatus.AVAILABLE:
|
||||
pytest.skip("Pygments/Pillow not installed")
|
||||
|
||||
def test_render_python(self, has_deps, tmp_path):
|
||||
tool = CodeSnippet()
|
||||
r = tool.execute({
|
||||
"code": "def hello():\n print('Hello, world!')\n",
|
||||
"language": "python",
|
||||
"theme": "monokai",
|
||||
"output_path": str(tmp_path / "test.png"),
|
||||
})
|
||||
assert r.success
|
||||
assert Path(r.data["output"]).exists()
|
||||
assert r.data["line_count"] == 3
|
||||
|
||||
def test_render_with_title(self, has_deps, tmp_path):
|
||||
tool = CodeSnippet()
|
||||
r = tool.execute({
|
||||
"code": "console.log('test');",
|
||||
"language": "javascript",
|
||||
"theme": "dracula",
|
||||
"title": "example.js",
|
||||
"output_path": str(tmp_path / "titled.png"),
|
||||
})
|
||||
assert r.success
|
||||
|
||||
def test_render_different_themes(self, has_deps, tmp_path):
|
||||
tool = CodeSnippet()
|
||||
for theme_name in ["monokai", "github_dark", "light"]:
|
||||
r = tool.execute({
|
||||
"code": "x = 42",
|
||||
"language": "python",
|
||||
"theme": theme_name,
|
||||
"output_path": str(tmp_path / f"{theme_name}.png"),
|
||||
})
|
||||
assert r.success, f"Theme {theme_name} failed"
|
||||
|
||||
|
||||
# ---- Contract: diagram_gen renders valid images ----
|
||||
|
||||
class TestDiagramGenUnit:
|
||||
@pytest.fixture
|
||||
def has_deps(self):
|
||||
tool = DiagramGen()
|
||||
if tool.get_status() != ToolStatus.AVAILABLE:
|
||||
pytest.skip("No diagram renderer available")
|
||||
|
||||
def test_render_box_diagram(self, has_deps, tmp_path):
|
||||
tool = DiagramGen()
|
||||
r = tool.execute({
|
||||
"diagram_type": "boxes",
|
||||
"title": "Test Flow",
|
||||
"boxes": [
|
||||
{"label": "Input", "color": "#2563eb"},
|
||||
{"label": "Process", "color": "#7c3aed"},
|
||||
{"label": "Output", "color": "#059669"},
|
||||
],
|
||||
"connections": [
|
||||
{"from": 0, "to": 1, "label": "data"},
|
||||
{"from": 1, "to": 2, "label": "result"},
|
||||
],
|
||||
"theme": "dark",
|
||||
"output_path": str(tmp_path / "boxes.png"),
|
||||
})
|
||||
assert r.success
|
||||
assert Path(r.data["output"]).exists()
|
||||
assert r.data["box_count"] == 3
|
||||
|
||||
def test_render_mermaid_fallback(self, has_deps, tmp_path):
|
||||
"""If mmdc not installed, falls back to text card."""
|
||||
tool = DiagramGen()
|
||||
r = tool.execute({
|
||||
"diagram_type": "mermaid",
|
||||
"definition": "graph TD\n A[Start] --> B[End]",
|
||||
"output_path": str(tmp_path / "mermaid.png"),
|
||||
})
|
||||
assert r.success
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Phase 3 contract tests — instruction-driven architecture.
|
||||
|
||||
Tests the new tools (TTS, music gen), pipeline manifests, style playbooks,
|
||||
stage director skills, meta skills, and the animated-explainer pipeline.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from lib.pipeline_loader import (
|
||||
load_pipeline,
|
||||
get_stage_order,
|
||||
get_required_tools,
|
||||
get_stage_skill,
|
||||
get_stage_review_focus,
|
||||
list_pipelines,
|
||||
)
|
||||
from lib.checkpoint import STAGES
|
||||
from schemas.artifacts import list_schemas
|
||||
from styles.playbook_loader import load_playbook, list_playbooks, validate_playbook
|
||||
from tools.base_tool import ToolTier
|
||||
from tools.audio.music_gen import MusicGen
|
||||
from tools.tool_registry import ToolRegistry
|
||||
from tools.audio.elevenlabs_tts import ElevenLabsTTS
|
||||
from tools.audio.openai_tts import OpenAITTS
|
||||
from tools.audio.piper_tts import PiperTTS
|
||||
from tools.audio.tts_selector import TTSSelector
|
||||
|
||||
|
||||
# ---- TTS Provider Tools ----
|
||||
|
||||
class TestElevenLabsTTS:
|
||||
def test_identity(self):
|
||||
tool = ElevenLabsTTS()
|
||||
info = tool.get_info()
|
||||
assert info["name"] == "elevenlabs_tts"
|
||||
assert info["tier"] == "voice"
|
||||
assert info["capability"] == "tts"
|
||||
assert info["provider"] == "elevenlabs"
|
||||
|
||||
def test_cost_estimate(self):
|
||||
tool = ElevenLabsTTS()
|
||||
cost = tool.estimate_cost({"text": "Hello world, this is a test."})
|
||||
assert cost > 0
|
||||
assert cost < 0.01 # short text should be cheap
|
||||
|
||||
def test_capabilities(self):
|
||||
tool = ElevenLabsTTS()
|
||||
assert "text_to_speech" in tool.capabilities
|
||||
assert "voice_selection" in tool.capabilities
|
||||
|
||||
|
||||
class TestPiperTTS:
|
||||
def test_identity(self):
|
||||
tool = PiperTTS()
|
||||
info = tool.get_info()
|
||||
assert info["name"] == "piper_tts"
|
||||
assert info["tier"] == "voice"
|
||||
assert info["capability"] == "tts"
|
||||
assert info["provider"] == "piper"
|
||||
|
||||
def test_cost_is_free(self):
|
||||
tool = PiperTTS()
|
||||
assert tool.estimate_cost({"text": "anything"}) == 0.0
|
||||
|
||||
def test_capabilities(self):
|
||||
tool = PiperTTS()
|
||||
assert "text_to_speech" in tool.capabilities
|
||||
assert "offline_generation" in tool.capabilities
|
||||
|
||||
|
||||
class TestMusicGen:
|
||||
def test_identity(self):
|
||||
tool = MusicGen()
|
||||
info = tool.get_info()
|
||||
assert info["name"] == "music_gen"
|
||||
assert info["tier"] == "generate"
|
||||
|
||||
def test_cost_estimate_scales_with_duration(self):
|
||||
tool = MusicGen()
|
||||
cost_30 = tool.estimate_cost({"prompt": "ambient", "duration_seconds": 30})
|
||||
cost_60 = tool.estimate_cost({"prompt": "ambient", "duration_seconds": 60})
|
||||
assert cost_60 > cost_30
|
||||
|
||||
def test_capabilities(self):
|
||||
tool = MusicGen()
|
||||
assert "generate_background_music" in tool.capabilities
|
||||
|
||||
|
||||
class TestNewToolsRegistry:
|
||||
def test_all_register(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(ElevenLabsTTS())
|
||||
reg.register(PiperTTS())
|
||||
reg.register(MusicGen())
|
||||
assert len(reg.list_all()) == 3
|
||||
|
||||
def test_voice_tier_tools(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(ElevenLabsTTS())
|
||||
reg.register(OpenAITTS())
|
||||
reg.register(PiperTTS())
|
||||
voice_tools = reg.get_by_tier(ToolTier.VOICE)
|
||||
assert len(voice_tools) == 3
|
||||
names = {t.name for t in voice_tools}
|
||||
assert names == {"elevenlabs_tts", "openai_tts", "piper_tts"}
|
||||
|
||||
|
||||
class TestCapabilityMetadata:
|
||||
def test_tts_tools_expose_capability_provider_and_location(self):
|
||||
tool = ElevenLabsTTS()
|
||||
info = tool.get_info()
|
||||
assert info["capability"] == "tts"
|
||||
assert info["provider"] == "elevenlabs"
|
||||
assert info["usage_location"].endswith("tools\\audio\\elevenlabs_tts.py") or info["usage_location"].endswith("tools/audio/elevenlabs_tts.py")
|
||||
assert "related_skills" in info
|
||||
assert "fallback_tools" in info
|
||||
|
||||
def test_provider_specific_tts_tools_register(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(ElevenLabsTTS())
|
||||
reg.register(OpenAITTS())
|
||||
reg.register(PiperTTS())
|
||||
reg.register(TTSSelector())
|
||||
assert {tool.name for tool in reg.get_by_capability("tts")} == {
|
||||
"elevenlabs_tts",
|
||||
"openai_tts",
|
||||
"piper_tts",
|
||||
"tts_selector",
|
||||
}
|
||||
assert {tool.name for tool in reg.get_by_provider("elevenlabs")} == {"elevenlabs_tts"}
|
||||
|
||||
def test_registry_catalog_views(self):
|
||||
reg = ToolRegistry()
|
||||
reg.register(ElevenLabsTTS())
|
||||
reg.register(OpenAITTS())
|
||||
reg.register(PiperTTS())
|
||||
catalog = reg.capability_catalog()
|
||||
assert "tts" in catalog
|
||||
providers = {item["provider"] for item in catalog["tts"] if item["provider"] != "selector"}
|
||||
assert providers == {"elevenlabs", "openai", "piper"}
|
||||
|
||||
|
||||
# ---- Animated Explainer Pipeline ----
|
||||
|
||||
class TestAnimatedExplainerManifest:
|
||||
def test_loads(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
assert manifest["name"] == "animated-explainer"
|
||||
assert manifest["version"] == "2.0"
|
||||
|
||||
def test_all_stages_present(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
stage_names = get_stage_order(manifest)
|
||||
expected = ["research", "proposal", "script", "scene_plan", "assets", "edit", "compose", "publish"]
|
||||
assert stage_names == expected
|
||||
|
||||
def test_every_stage_has_skill(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
for stage in manifest["stages"]:
|
||||
assert "skill" in stage, f"Stage {stage['name']} missing skill"
|
||||
skill = get_stage_skill(manifest, stage["name"])
|
||||
assert skill is not None
|
||||
assert skill.startswith("pipelines/explainer/")
|
||||
|
||||
def test_every_stage_has_review_focus(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
for stage in manifest["stages"]:
|
||||
focus = get_stage_review_focus(manifest, stage["name"])
|
||||
assert len(focus) >= 3, f"Stage {stage['name']} needs more review focus items"
|
||||
|
||||
def test_required_tools_complete(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
tools = get_required_tools(manifest)
|
||||
expected = {"tts_selector", "image_selector", "video_compose", "audio_mixer"}
|
||||
for t in expected:
|
||||
assert t in tools, f"Missing required tool: {t}"
|
||||
|
||||
def test_creative_stages_require_human_approval(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
approval_stages = {"proposal", "script", "scene_plan", "publish"}
|
||||
for stage in manifest["stages"]:
|
||||
if stage["name"] in approval_stages:
|
||||
assert stage.get("human_approval_default") is True, (
|
||||
f"Stage {stage['name']} should require human approval"
|
||||
)
|
||||
|
||||
def test_listed(self):
|
||||
assert "animated-explainer" in list_pipelines()
|
||||
|
||||
|
||||
# ---- Style Playbooks ----
|
||||
|
||||
class TestStylePlaybooks:
|
||||
def test_all_listed(self):
|
||||
playbooks = list_playbooks()
|
||||
assert "clean-professional" in playbooks
|
||||
assert "flat-motion-graphics" in playbooks
|
||||
assert "minimalist-diagram" in playbooks
|
||||
|
||||
@pytest.mark.parametrize("name", ["clean-professional", "flat-motion-graphics", "minimalist-diagram"])
|
||||
def test_loads_and_validates(self, name):
|
||||
pb = load_playbook(name)
|
||||
assert pb["identity"]["name"]
|
||||
assert pb["identity"]["category"]
|
||||
|
||||
@pytest.mark.parametrize("name", ["clean-professional", "flat-motion-graphics", "minimalist-diagram"])
|
||||
def test_has_required_sections(self, name):
|
||||
pb = load_playbook(name)
|
||||
assert "visual_language" in pb
|
||||
assert "typography" in pb
|
||||
assert "motion" in pb
|
||||
assert "audio" in pb
|
||||
assert "asset_generation" in pb
|
||||
assert "quality_rules" in pb
|
||||
assert len(pb["quality_rules"]) >= 3
|
||||
|
||||
@pytest.mark.parametrize("name", ["clean-professional", "flat-motion-graphics", "minimalist-diagram"])
|
||||
def test_color_palette_complete(self, name):
|
||||
pb = load_playbook(name)
|
||||
palette = pb["visual_language"]["color_palette"]
|
||||
assert "primary" in palette
|
||||
assert "accent" in palette
|
||||
assert "background" in palette
|
||||
assert "text" in palette
|
||||
|
||||
@pytest.mark.parametrize("name", ["clean-professional", "flat-motion-graphics", "minimalist-diagram"])
|
||||
def test_pacing_rules_present(self, name):
|
||||
pb = load_playbook(name)
|
||||
pacing = pb["motion"]["pacing_rules"]
|
||||
assert "min_scene_hold_seconds" in pacing
|
||||
assert "max_scene_hold_seconds" in pacing
|
||||
|
||||
def test_compatible_with_manifest(self):
|
||||
manifest = load_pipeline("animated-explainer")
|
||||
available = list_playbooks()
|
||||
for name in manifest.get("compatible_playbooks", []):
|
||||
assert name in available, f"Manifest references unavailable playbook: {name}"
|
||||
|
||||
|
||||
# ---- Skills Existence ----
|
||||
|
||||
class TestSkillsExist:
|
||||
SKILLS_DIR = PROJECT_ROOT / "skills"
|
||||
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"pipelines/explainer/idea-director.md",
|
||||
"pipelines/explainer/script-director.md",
|
||||
"pipelines/explainer/scene-director.md",
|
||||
"pipelines/explainer/asset-director.md",
|
||||
"pipelines/explainer/edit-director.md",
|
||||
"pipelines/explainer/compose-director.md",
|
||||
"pipelines/explainer/publish-director.md",
|
||||
])
|
||||
def test_director_skills_exist(self, skill_path):
|
||||
full_path = self.SKILLS_DIR / skill_path
|
||||
assert full_path.exists(), f"Missing director skill: {skill_path}"
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
assert len(content) > 500, f"Skill too short to be useful: {skill_path}"
|
||||
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"meta/reviewer.md",
|
||||
"meta/checkpoint-protocol.md",
|
||||
"meta/skill-creator.md",
|
||||
])
|
||||
def test_meta_skills_exist(self, skill_path):
|
||||
full_path = self.SKILLS_DIR / skill_path
|
||||
assert full_path.exists(), f"Missing meta skill: {skill_path}"
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
assert len(content) > 500, f"Skill too short to be useful: {skill_path}"
|
||||
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"pipelines/explainer/idea-director.md",
|
||||
"pipelines/explainer/script-director.md",
|
||||
"pipelines/explainer/scene-director.md",
|
||||
"pipelines/explainer/asset-director.md",
|
||||
"pipelines/explainer/edit-director.md",
|
||||
"pipelines/explainer/compose-director.md",
|
||||
"pipelines/explainer/publish-director.md",
|
||||
])
|
||||
def test_director_skills_have_required_sections(self, skill_path):
|
||||
content = (self.SKILLS_DIR / skill_path).read_text(encoding="utf-8")
|
||||
assert "## When to Use" in content
|
||||
assert "## Process" in content or "## Protocol" in content
|
||||
assert "Self-Evaluate" in content or "self-evaluate" in content.lower()
|
||||
|
||||
@pytest.mark.parametrize("skill_path", [
|
||||
"meta/reviewer.md",
|
||||
"meta/checkpoint-protocol.md",
|
||||
"meta/skill-creator.md",
|
||||
])
|
||||
def test_meta_skills_have_required_sections(self, skill_path):
|
||||
content = (self.SKILLS_DIR / skill_path).read_text(encoding="utf-8")
|
||||
assert "## When to Use" in content
|
||||
assert "## Protocol" in content or "## Process" in content
|
||||
|
||||
|
||||
# ---- Remotion Scaffold ----
|
||||
|
||||
class TestRemotionScaffold:
|
||||
REMOTION_DIR = PROJECT_ROOT / "remotion-composer"
|
||||
|
||||
def test_package_json_exists(self):
|
||||
assert (self.REMOTION_DIR / "package.json").exists()
|
||||
|
||||
def test_entry_point_exists(self):
|
||||
assert (self.REMOTION_DIR / "src" / "index.tsx").exists()
|
||||
|
||||
def test_root_composition_exists(self):
|
||||
assert (self.REMOTION_DIR / "src" / "Root.tsx").exists()
|
||||
|
||||
def test_explainer_component_exists(self):
|
||||
assert (self.REMOTION_DIR / "src" / "Explainer.tsx").exists()
|
||||
|
||||
def test_text_card_component_exists(self):
|
||||
assert (self.REMOTION_DIR / "src" / "components" / "TextCard.tsx").exists()
|
||||
|
||||
def test_stat_card_component_exists(self):
|
||||
assert (self.REMOTION_DIR / "src" / "components" / "StatCard.tsx").exists()
|
||||
|
||||
|
||||
# ---- Video Compose Operations ----
|
||||
|
||||
class TestVideoComposeOperations:
|
||||
def test_render_operation_exists(self):
|
||||
from tools.video.video_compose import VideoCompose
|
||||
tool = VideoCompose()
|
||||
ops = tool.input_schema["properties"]["operation"]["enum"]
|
||||
assert "render" in ops
|
||||
assert "remotion_render" in ops
|
||||
|
||||
def test_render_rejects_missing_inputs(self):
|
||||
from tools.video.video_compose import VideoCompose
|
||||
tool = VideoCompose()
|
||||
result = tool.execute({"operation": "render"})
|
||||
assert not result.success
|
||||
assert "edit_decisions" in result.error
|
||||
Reference in New Issue
Block a user