1
0

refactor: Phase 3 — reorganize tests into module-mirroring subdirectories

Move 97 test files from flat tests/ into 13 subdirectories:
  tests/dashboard/   (8 files — routes, mobile, mission control)
  tests/swarm/       (17 files — coordinator, docker, routing, tasks)
  tests/timmy/       (12 files — agent, backends, CLI, tools)
  tests/self_coding/  (14 files — git safety, indexer, self-modify)
  tests/lightning/   (3 files — L402, LND, interface)
  tests/creative/    (8 files — assembler, director, image/music/video)
  tests/integrations/ (10 files — chat bridge, telegram, voice, websocket)
  tests/mcp/         (4 files — bootstrap, discovery, executor)
  tests/spark/       (3 files — engine, tools, events)
  tests/hands/       (3 files — registry, oracle, phase5)
  tests/scripture/   (1 file)
  tests/infrastructure/ (3 files — router cascade, API)
  tests/security/    (3 files — XSS, regression)

Fix Path(__file__) reference in test_mobile_scenarios.py for new depth.
Add __init__.py to all test subdirectories.

Tests: 1503 passed, 9 failed (pre-existing), 53 errors (pre-existing)

https://claude.ai/code/session_019oMFNvD8uSGSSmBMGkBfQN
This commit is contained in:
Claude
2026-02-26 21:21:28 +00:00
parent 6045077144
commit 4e11dd2490
104 changed files with 57 additions and 3 deletions

View File

View File

@@ -0,0 +1,69 @@
"""Tests for creative.assembler — Video assembly engine.
MoviePy is mocked for CI; these tests verify the interface contracts.
"""
import pytest
from unittest.mock import patch, MagicMock
from creative.assembler import (
ASSEMBLER_TOOL_CATALOG,
stitch_clips,
overlay_audio,
add_title_card,
add_subtitles,
export_final,
_MOVIEPY_AVAILABLE,
)
class TestAssemblerToolCatalog:
def test_catalog_has_all_tools(self):
expected = {
"stitch_clips", "overlay_audio", "add_title_card",
"add_subtitles", "export_final",
}
assert expected == set(ASSEMBLER_TOOL_CATALOG.keys())
def test_catalog_entries_callable(self):
for tool_id, info in ASSEMBLER_TOOL_CATALOG.items():
assert callable(info["fn"])
assert "name" in info
assert "description" in info
class TestStitchClipsInterface:
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
def test_raises_on_empty_clips(self):
"""Stitch with no clips should fail gracefully."""
# MoviePy would fail on empty list
with pytest.raises(Exception):
stitch_clips([])
class TestOverlayAudioInterface:
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
def test_overlay_requires_valid_paths(self):
with pytest.raises(Exception):
overlay_audio("/nonexistent/video.mp4", "/nonexistent/audio.wav")
class TestAddTitleCardInterface:
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
def test_add_title_requires_valid_video(self):
with pytest.raises(Exception):
add_title_card("/nonexistent/video.mp4", "Title")
class TestAddSubtitlesInterface:
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
def test_requires_valid_video(self):
with pytest.raises(Exception):
add_subtitles("/nonexistent.mp4", [{"text": "Hi", "start": 0, "end": 1}])
class TestExportFinalInterface:
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
def test_requires_valid_video(self):
with pytest.raises(Exception):
export_final("/nonexistent/video.mp4")

View File

@@ -0,0 +1,275 @@
"""Integration tests for creative.assembler — real files, no mocks.
Every test creates actual media files (PNG, WAV, MP4), runs them through
the assembler functions, and inspects the output with MoviePy / Pillow.
"""
import pytest
from pathlib import Path
from moviepy import VideoFileClip, AudioFileClip
from creative.assembler import (
stitch_clips,
overlay_audio,
add_title_card,
add_subtitles,
export_final,
)
from fixtures.media import (
make_audio_track,
make_video_clip,
make_scene_clips,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def media_dir(tmp_path):
"""Isolated directory for generated media."""
d = tmp_path / "media"
d.mkdir()
return d
@pytest.fixture
def two_clips(media_dir):
"""Two real 3-second MP4 clips."""
return make_scene_clips(
media_dir, ["Scene A", "Scene B"],
duration_per_clip=3.0, fps=12, width=320, height=180,
)
@pytest.fixture
def five_clips(media_dir):
"""Five real 2-second MP4 clips — enough for a short video."""
return make_scene_clips(
media_dir,
["Dawn", "Sunrise", "Mountains", "River", "Sunset"],
duration_per_clip=2.0, fps=12, width=320, height=180,
)
@pytest.fixture
def audio_10s(media_dir):
"""A real 10-second WAV audio track."""
return make_audio_track(media_dir / "track.wav", duration_seconds=10.0)
@pytest.fixture
def audio_30s(media_dir):
"""A real 30-second WAV audio track."""
return make_audio_track(
media_dir / "track_long.wav",
duration_seconds=30.0,
frequency=330.0,
)
# ── Stitch clips ─────────────────────────────────────────────────────────────
class TestStitchClipsReal:
def test_stitch_two_clips_no_transition(self, two_clips, tmp_path):
"""Stitching 2 x 3s clips → ~6s video."""
out = tmp_path / "stitched.mp4"
result = stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(out),
)
assert result["success"]
assert result["clip_count"] == 2
assert out.exists()
assert out.stat().st_size > 1000 # non-trivial file
video = VideoFileClip(str(out))
assert video.duration == pytest.approx(6.0, abs=0.5)
assert video.size == [320, 180]
video.close()
def test_stitch_with_crossfade(self, two_clips, tmp_path):
"""Cross-fade transition shortens total duration."""
out = tmp_path / "crossfade.mp4"
result = stitch_clips(
[str(p) for p in two_clips],
transition_duration=1.0,
output_path=str(out),
)
assert result["success"]
video = VideoFileClip(str(out))
# 2 x 3s - 1s overlap = ~5s
assert video.duration == pytest.approx(5.0, abs=1.0)
video.close()
def test_stitch_five_clips(self, five_clips, tmp_path):
"""Stitch 5 clips → continuous video with correct frame count."""
out = tmp_path / "five.mp4"
result = stitch_clips(
[str(p) for p in five_clips],
transition_duration=0.5,
output_path=str(out),
)
assert result["success"]
assert result["clip_count"] == 5
video = VideoFileClip(str(out))
# 5 x 2s - 4 * 0.5s overlap = 8s
assert video.duration >= 7.0
assert video.size == [320, 180]
video.close()
# ── Audio overlay ─────────────────────────────────────────────────────────────
class TestOverlayAudioReal:
def test_overlay_adds_audio_stream(self, two_clips, audio_10s, tmp_path):
"""Overlaying audio onto a silent video produces audible output."""
# First stitch clips
stitched = tmp_path / "silent.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
out = tmp_path / "with_audio.mp4"
result = overlay_audio(str(stitched), str(audio_10s), output_path=str(out))
assert result["success"]
assert out.exists()
video = VideoFileClip(str(out))
assert video.audio is not None # has audio stream
assert video.duration == pytest.approx(6.0, abs=0.5)
video.close()
def test_audio_trimmed_to_video_length(self, two_clips, audio_30s, tmp_path):
"""30s audio track is trimmed to match ~6s video duration."""
stitched = tmp_path / "short.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
out = tmp_path / "trimmed.mp4"
result = overlay_audio(str(stitched), str(audio_30s), output_path=str(out))
assert result["success"]
video = VideoFileClip(str(out))
# Audio should be trimmed to video length, not 30s
assert video.duration < 10.0
video.close()
# ── Title cards ───────────────────────────────────────────────────────────────
class TestAddTitleCardReal:
def test_prepend_title_card(self, two_clips, tmp_path):
"""Title card at start adds to total duration."""
stitched = tmp_path / "base.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
base_video = VideoFileClip(str(stitched))
base_duration = base_video.duration
base_video.close()
out = tmp_path / "titled.mp4"
result = add_title_card(
str(stitched),
title="My Music Video",
duration=3.0,
position="start",
output_path=str(out),
)
assert result["success"]
assert result["title"] == "My Music Video"
video = VideoFileClip(str(out))
# Title card (3s) + base video (~6s) = ~9s
assert video.duration == pytest.approx(base_duration + 3.0, abs=1.0)
video.close()
def test_append_credits(self, two_clips, tmp_path):
"""Credits card at end adds to total duration."""
clip_path = str(two_clips[0]) # single 3s clip
out = tmp_path / "credits.mp4"
result = add_title_card(
clip_path,
title="THE END",
duration=2.0,
position="end",
output_path=str(out),
)
assert result["success"]
video = VideoFileClip(str(out))
# 3s clip + 2s credits = ~5s
assert video.duration == pytest.approx(5.0, abs=1.0)
video.close()
# ── Subtitles ─────────────────────────────────────────────────────────────────
class TestAddSubtitlesReal:
def test_burn_captions(self, two_clips, tmp_path):
"""Subtitles are burned onto the video (duration unchanged)."""
stitched = tmp_path / "base.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
captions = [
{"text": "Welcome to the show", "start": 0.0, "end": 2.0},
{"text": "Here we go!", "start": 2.5, "end": 4.5},
{"text": "Finale", "start": 5.0, "end": 6.0},
]
out = tmp_path / "subtitled.mp4"
result = add_subtitles(str(stitched), captions, output_path=str(out))
assert result["success"]
assert result["caption_count"] == 3
video = VideoFileClip(str(out))
# Duration should be unchanged
assert video.duration == pytest.approx(6.0, abs=0.5)
assert video.size == [320, 180]
video.close()
# ── Export final ──────────────────────────────────────────────────────────────
class TestExportFinalReal:
def test_reencodes_video(self, two_clips, tmp_path):
"""Final export produces a valid re-encoded file."""
clip_path = str(two_clips[0])
out = tmp_path / "final.mp4"
result = export_final(
clip_path,
output_path=str(out),
codec="libx264",
bitrate="2000k",
)
assert result["success"]
assert result["codec"] == "libx264"
assert out.exists()
assert out.stat().st_size > 500
video = VideoFileClip(str(out))
assert video.duration == pytest.approx(3.0, abs=0.5)
video.close()

View File

@@ -0,0 +1,190 @@
"""Tests for creative.director — Creative Director pipeline.
Tests project management, pipeline orchestration, and tool catalogue.
All AI model calls are mocked.
"""
import pytest
from unittest.mock import patch, MagicMock
from creative.director import (
create_project,
get_project,
list_projects,
run_storyboard,
run_music,
run_video_generation,
run_assembly,
run_full_pipeline,
CreativeProject,
DIRECTOR_TOOL_CATALOG,
_projects,
)
@pytest.fixture(autouse=True)
def clear_projects():
"""Clear project store between tests."""
_projects.clear()
yield
_projects.clear()
@pytest.fixture
def sample_project(tmp_path):
"""Create a sample project with scenes."""
with patch("creative.director._project_dir", return_value=tmp_path):
result = create_project(
title="Test Video",
description="A test creative project",
scenes=[
{"description": "A sunrise over mountains"},
{"description": "A river flowing through a valley"},
{"description": "A sunset over the ocean"},
],
lyrics="La la la, the sun rises high",
)
return result["project"]["id"]
class TestCreateProject:
def test_creates_project(self, tmp_path):
with patch("creative.director._project_dir", return_value=tmp_path):
result = create_project("My Video", "A cool video")
assert result["success"]
assert result["project"]["title"] == "My Video"
assert result["project"]["status"] == "planning"
def test_project_has_id(self, tmp_path):
with patch("creative.director._project_dir", return_value=tmp_path):
result = create_project("Test", "Test")
assert len(result["project"]["id"]) == 12
def test_project_with_scenes(self, tmp_path):
with patch("creative.director._project_dir", return_value=tmp_path):
result = create_project(
"Scenes", "With scenes",
scenes=[{"description": "Scene 1"}, {"description": "Scene 2"}],
)
assert result["project"]["scene_count"] == 2
class TestGetProject:
def test_get_existing(self, sample_project):
result = get_project(sample_project)
assert result is not None
assert result["title"] == "Test Video"
def test_get_nonexistent(self):
assert get_project("bogus") is None
class TestListProjects:
def test_empty(self):
assert list_projects() == []
def test_with_projects(self, sample_project, tmp_path):
with patch("creative.director._project_dir", return_value=tmp_path):
create_project("Second", "desc")
assert len(list_projects()) == 2
class TestRunStoryboard:
def test_fails_without_project(self):
result = run_storyboard("bogus")
assert not result["success"]
assert "not found" in result["error"]
def test_fails_without_scenes(self, tmp_path):
with patch("creative.director._project_dir", return_value=tmp_path):
result = create_project("Empty", "No scenes")
pid = result["project"]["id"]
result = run_storyboard(pid)
assert not result["success"]
assert "No scenes" in result["error"]
def test_generates_frames(self, sample_project, tmp_path):
mock_result = {
"success": True,
"frame_count": 3,
"frames": [
{"path": "/fake/1.png", "scene_index": 0, "prompt": "sunrise"},
{"path": "/fake/2.png", "scene_index": 1, "prompt": "river"},
{"path": "/fake/3.png", "scene_index": 2, "prompt": "sunset"},
],
}
with patch("tools.image_tools.generate_storyboard", return_value=mock_result):
with patch("creative.director._save_project"):
result = run_storyboard(sample_project)
assert result["success"]
assert result["frame_count"] == 3
class TestRunMusic:
def test_fails_without_project(self):
result = run_music("bogus")
assert not result["success"]
def test_generates_track(self, sample_project):
mock_result = {
"success": True, "path": "/fake/song.wav",
"genre": "pop", "duration": 60,
}
with patch("tools.music_tools.generate_song", return_value=mock_result):
with patch("creative.director._save_project"):
result = run_music(sample_project, genre="pop")
assert result["success"]
assert result["path"] == "/fake/song.wav"
class TestRunVideoGeneration:
def test_fails_without_project(self):
result = run_video_generation("bogus")
assert not result["success"]
def test_generates_clips(self, sample_project):
mock_clip = {
"success": True, "path": "/fake/clip.mp4",
"duration": 5,
}
with patch("tools.video_tools.generate_video_clip", return_value=mock_clip):
with patch("tools.video_tools.image_to_video", return_value=mock_clip):
with patch("creative.director._save_project"):
result = run_video_generation(sample_project)
assert result["success"]
assert result["clip_count"] == 3
class TestRunAssembly:
def test_fails_without_project(self):
result = run_assembly("bogus")
assert not result["success"]
def test_fails_without_clips(self, sample_project):
result = run_assembly(sample_project)
assert not result["success"]
assert "No video clips" in result["error"]
class TestCreativeProject:
def test_to_dict(self):
p = CreativeProject(title="Test", description="Desc")
d = p.to_dict()
assert d["title"] == "Test"
assert d["status"] == "planning"
assert d["scene_count"] == 0
assert d["has_storyboard"] is False
assert d["has_music"] is False
class TestDirectorToolCatalog:
def test_catalog_has_all_tools(self):
expected = {
"create_project", "run_storyboard", "run_music",
"run_video_generation", "run_assembly", "run_full_pipeline",
}
assert expected == set(DIRECTOR_TOOL_CATALOG.keys())
def test_catalog_entries_callable(self):
for tool_id, info in DIRECTOR_TOOL_CATALOG.items():
assert callable(info["fn"])

View File

@@ -0,0 +1,61 @@
"""Tests for the Creative Studio dashboard route."""
import os
import pytest
os.environ.setdefault("TIMMY_TEST_MODE", "1")
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
"""Test client with temp DB paths."""
monkeypatch.setattr("swarm.tasks.DB_PATH", tmp_path / "swarm.db")
monkeypatch.setattr("swarm.registry.DB_PATH", tmp_path / "swarm.db")
monkeypatch.setattr("swarm.stats.DB_PATH", tmp_path / "swarm.db")
monkeypatch.setattr("swarm.learner.DB_PATH", tmp_path / "swarm.db")
from dashboard.app import app
return TestClient(app)
class TestCreativeStudioPage:
def test_creative_page_loads(self, client):
resp = client.get("/creative/ui")
assert resp.status_code == 200
assert "Creative Studio" in resp.text
def test_creative_page_has_tabs(self, client):
resp = client.get("/creative/ui")
assert "tab-images" in resp.text
assert "tab-music" in resp.text
assert "tab-video" in resp.text
assert "tab-director" in resp.text
def test_creative_page_shows_personas(self, client):
resp = client.get("/creative/ui")
assert "Pixel" in resp.text
assert "Lyra" in resp.text
assert "Reel" in resp.text
class TestCreativeAPI:
def test_projects_api_empty(self, client):
resp = client.get("/creative/api/projects")
assert resp.status_code == 200
data = resp.json()
assert "projects" in data
def test_genres_api(self, client):
resp = client.get("/creative/api/genres")
assert resp.status_code == 200
data = resp.json()
assert "genres" in data
def test_video_styles_api(self, client):
resp = client.get("/creative/api/video-styles")
assert resp.status_code == 200
data = resp.json()
assert "styles" in data
assert "resolutions" in data

View File

@@ -0,0 +1,120 @@
"""Tests for tools.image_tools — Image generation (Pixel persona).
Heavy AI model tests are skipped; only catalogue, metadata, and
interface tests run in CI.
"""
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from tools.image_tools import (
IMAGE_TOOL_CATALOG,
generate_image,
generate_storyboard,
image_variations,
_save_metadata,
)
class TestImageToolCatalog:
def test_catalog_has_all_tools(self):
expected = {"generate_image", "generate_storyboard", "image_variations"}
assert expected == set(IMAGE_TOOL_CATALOG.keys())
def test_catalog_entries_have_required_keys(self):
for tool_id, info in IMAGE_TOOL_CATALOG.items():
assert "name" in info
assert "description" in info
assert "fn" in info
assert callable(info["fn"])
class TestSaveMetadata:
def test_saves_json_sidecar(self, tmp_path):
img_path = tmp_path / "test.png"
img_path.write_bytes(b"fake image")
meta = {"prompt": "a cat", "width": 512}
result = _save_metadata(img_path, meta)
assert result.suffix == ".json"
assert result.exists()
import json
data = json.loads(result.read_text())
assert data["prompt"] == "a cat"
class TestGenerateImageInterface:
def test_raises_without_creative_deps(self):
"""generate_image raises ImportError when diffusers not available."""
with patch("tools.image_tools._pipeline", None):
with patch("tools.image_tools._get_pipeline", side_effect=ImportError("no diffusers")):
with pytest.raises(ImportError):
generate_image("a cat")
def test_generate_image_with_mocked_pipeline(self, tmp_path):
"""generate_image works end-to-end with a mocked pipeline."""
import sys
mock_image = MagicMock()
mock_image.save = MagicMock()
mock_pipe = MagicMock()
mock_pipe.device = "cpu"
mock_pipe.return_value.images = [mock_image]
mock_torch = MagicMock()
mock_torch.Generator.return_value = MagicMock()
with patch.dict(sys.modules, {"torch": mock_torch}):
with patch("tools.image_tools._get_pipeline", return_value=mock_pipe):
with patch("tools.image_tools._output_dir", return_value=tmp_path):
result = generate_image("a cat", width=512, height=512, steps=1)
assert result["success"]
assert result["prompt"] == "a cat"
assert result["width"] == 512
assert "path" in result
class TestGenerateStoryboardInterface:
def test_calls_generate_image_per_scene(self):
"""Storyboard calls generate_image once per scene."""
call_count = 0
def mock_gen_image(prompt, **kwargs):
nonlocal call_count
call_count += 1
return {
"success": True, "path": f"/fake/{call_count}.png",
"id": str(call_count), "prompt": prompt,
}
with patch("tools.image_tools.generate_image", side_effect=mock_gen_image):
result = generate_storyboard(
["sunrise", "mountain peak", "sunset"],
steps=1,
)
assert result["success"]
assert result["frame_count"] == 3
assert len(result["frames"]) == 3
assert call_count == 3
class TestImageVariationsInterface:
def test_generates_multiple_variations(self):
"""image_variations generates the requested number of results."""
def mock_gen_image(prompt, **kwargs):
return {
"success": True, "path": "/fake.png",
"id": "x", "prompt": prompt,
"seed": kwargs.get("seed"),
}
with patch("tools.image_tools.generate_image", side_effect=mock_gen_image):
result = image_variations("a dog", count=3, steps=1)
assert result["success"]
assert result["count"] == 3
assert len(result["variations"]) == 3

View File

@@ -0,0 +1,124 @@
"""Tests for tools.music_tools — Music generation (Lyra persona).
Heavy AI model tests are skipped; only catalogue, interface, and
metadata tests run in CI.
"""
import pytest
from unittest.mock import patch, MagicMock
from tools.music_tools import (
MUSIC_TOOL_CATALOG,
GENRES,
list_genres,
generate_song,
generate_instrumental,
generate_vocals,
)
class TestMusicToolCatalog:
def test_catalog_has_all_tools(self):
expected = {
"generate_song", "generate_instrumental",
"generate_vocals", "list_genres",
}
assert expected == set(MUSIC_TOOL_CATALOG.keys())
def test_catalog_entries_have_required_keys(self):
for tool_id, info in MUSIC_TOOL_CATALOG.items():
assert "name" in info
assert "description" in info
assert "fn" in info
assert callable(info["fn"])
class TestListGenres:
def test_returns_genre_list(self):
result = list_genres()
assert result["success"]
assert len(result["genres"]) > 10
assert "pop" in result["genres"]
assert "cinematic" in result["genres"]
class TestGenres:
def test_common_genres_present(self):
for genre in ["pop", "rock", "hip-hop", "jazz", "electronic", "classical"]:
assert genre in GENRES
class TestGenerateSongInterface:
def test_raises_without_ace_step(self):
with patch("tools.music_tools._model", None):
with patch("tools.music_tools._get_model", side_effect=ImportError("no ace-step")):
with pytest.raises(ImportError):
generate_song("la la la")
def test_duration_clamped(self):
"""Duration is clamped to 30240 range."""
mock_audio = MagicMock()
mock_audio.save = MagicMock()
mock_model = MagicMock()
mock_model.generate.return_value = mock_audio
with patch("tools.music_tools._get_model", return_value=mock_model):
with patch("tools.music_tools._output_dir", return_value=MagicMock()):
with patch("tools.music_tools._save_metadata"):
# Should clamp 5 to 30
generate_song("lyrics", duration=5)
call_kwargs = mock_model.generate.call_args[1]
assert call_kwargs["duration"] == 30
def test_generate_song_with_mocked_model(self, tmp_path):
mock_audio = MagicMock()
mock_audio.save = MagicMock()
mock_model = MagicMock()
mock_model.generate.return_value = mock_audio
with patch("tools.music_tools._get_model", return_value=mock_model):
with patch("tools.music_tools._output_dir", return_value=tmp_path):
result = generate_song(
"hello world", genre="rock", duration=60, title="Test Song"
)
assert result["success"]
assert result["genre"] == "rock"
assert result["title"] == "Test Song"
assert result["duration"] == 60
class TestGenerateInstrumentalInterface:
def test_with_mocked_model(self, tmp_path):
mock_audio = MagicMock()
mock_audio.save = MagicMock()
mock_model = MagicMock()
mock_model.generate.return_value = mock_audio
with patch("tools.music_tools._get_model", return_value=mock_model):
with patch("tools.music_tools._output_dir", return_value=tmp_path):
result = generate_instrumental("epic orchestral", genre="cinematic")
assert result["success"]
assert result["genre"] == "cinematic"
assert result["instrumental"] is True
class TestGenerateVocalsInterface:
def test_with_mocked_model(self, tmp_path):
mock_audio = MagicMock()
mock_audio.save = MagicMock()
mock_model = MagicMock()
mock_model.generate.return_value = mock_audio
with patch("tools.music_tools._get_model", return_value=mock_model):
with patch("tools.music_tools._output_dir", return_value=tmp_path):
result = generate_vocals("do re mi", style="jazz")
assert result["success"]
assert result["vocals_only"] is True
assert result["style"] == "jazz"

View File

@@ -0,0 +1,444 @@
"""Integration test: end-to-end music video pipeline with real media files.
Exercises the Creative Director pipeline and Assembler with genuine PNG,
WAV, and MP4 files. Only AI model inference is replaced with fixture
generators; all MoviePy / FFmpeg operations run for real.
The final output video is inspected for:
- Duration — correct within tolerance
- Resolution — 320x180 (fixture default)
- Audio stream — present
- File size — non-trivial (>10 kB)
"""
import pytest
from pathlib import Path
from unittest.mock import patch
from moviepy import VideoFileClip
from creative.director import (
create_project,
run_storyboard,
run_music,
run_video_generation,
run_assembly,
run_full_pipeline,
_projects,
)
from creative.assembler import (
stitch_clips,
overlay_audio,
add_title_card,
add_subtitles,
export_final,
)
from fixtures.media import (
make_storyboard,
make_audio_track,
make_video_clip,
make_scene_clips,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
SCENES = [
{"description": "Dawn breaks over misty mountains", "duration": 4},
{"description": "A river carves through green valleys", "duration": 4},
{"description": "Wildflowers sway in warm sunlight", "duration": 4},
{"description": "Clouds gather as evening approaches", "duration": 4},
{"description": "Stars emerge over a quiet lake", "duration": 4},
]
@pytest.fixture(autouse=True)
def clear_projects():
"""Clear in-memory project store between tests."""
_projects.clear()
yield
_projects.clear()
@pytest.fixture
def media_dir(tmp_path):
d = tmp_path / "media"
d.mkdir()
return d
@pytest.fixture
def scene_defs():
"""Five-scene creative brief for a short music video."""
return [dict(s) for s in SCENES]
@pytest.fixture
def storyboard_frames(media_dir):
"""Real PNG storyboard frames for all scenes."""
return make_storyboard(
media_dir / "frames",
[s["description"][:20] for s in SCENES],
width=320, height=180,
)
@pytest.fixture
def audio_track(media_dir):
"""Real 25-second WAV audio track."""
return make_audio_track(
media_dir / "soundtrack.wav",
duration_seconds=25.0,
frequency=440.0,
)
@pytest.fixture
def video_clips(media_dir):
"""Real 4-second MP4 clips, one per scene (~20s total)."""
return make_scene_clips(
media_dir / "clips",
[s["description"][:20] for s in SCENES],
duration_per_clip=4.0,
fps=12,
width=320,
height=180,
)
# ── Direct assembly (zero AI mocking) ───────────────────────────────────────
class TestMusicVideoAssembly:
"""Build a real music video from fixture clips + audio, inspect output."""
def test_full_music_video(self, video_clips, audio_track, tmp_path):
"""Stitch 5 clips -> overlay audio -> title -> credits -> inspect."""
# 1. Stitch with crossfade
stitched = tmp_path / "stitched.mp4"
stitch_result = stitch_clips(
[str(p) for p in video_clips],
transition_duration=0.5,
output_path=str(stitched),
)
assert stitch_result["success"]
assert stitch_result["clip_count"] == 5
# 2. Overlay audio
with_audio = tmp_path / "with_audio.mp4"
audio_result = overlay_audio(
str(stitched), str(audio_track),
output_path=str(with_audio),
)
assert audio_result["success"]
# 3. Title card at start
titled = tmp_path / "titled.mp4"
title_result = add_title_card(
str(with_audio),
title="Dawn to Dusk",
duration=3.0,
position="start",
output_path=str(titled),
)
assert title_result["success"]
# 4. Credits at end
final_path = tmp_path / "final_music_video.mp4"
credits_result = add_title_card(
str(titled),
title="THE END",
duration=2.0,
position="end",
output_path=str(final_path),
)
assert credits_result["success"]
# ── Inspect final video ──────────────────────────────────────────
assert final_path.exists()
assert final_path.stat().st_size > 10_000 # non-trivial file
video = VideoFileClip(str(final_path))
# Duration: 5x4s - 4x0.5s crossfade = 18s + 3s title + 2s credits = 23s
expected_body = 5 * 4.0 - 4 * 0.5 # 18s
expected_total = expected_body + 3.0 + 2.0 # 23s
assert video.duration >= 15.0 # floor sanity check
assert video.duration == pytest.approx(expected_total, abs=3.0)
# Resolution
assert video.size == [320, 180]
# Audio present
assert video.audio is not None
video.close()
def test_with_subtitles(self, video_clips, audio_track, tmp_path):
"""Full video with burned-in captions."""
# Stitch without transitions for predictable duration
stitched = tmp_path / "stitched.mp4"
stitch_clips(
[str(p) for p in video_clips],
transition_duration=0,
output_path=str(stitched),
)
# Overlay audio
with_audio = tmp_path / "with_audio.mp4"
overlay_audio(
str(stitched), str(audio_track),
output_path=str(with_audio),
)
# Burn subtitles — one caption per scene
captions = [
{"text": "Dawn breaks over misty mountains", "start": 0.0, "end": 3.5},
{"text": "A river carves through green valleys", "start": 4.0, "end": 7.5},
{"text": "Wildflowers sway in warm sunlight", "start": 8.0, "end": 11.5},
{"text": "Clouds gather as evening approaches", "start": 12.0, "end": 15.5},
{"text": "Stars emerge over a quiet lake", "start": 16.0, "end": 19.5},
]
final = tmp_path / "subtitled_video.mp4"
result = add_subtitles(str(with_audio), captions, output_path=str(final))
assert result["success"]
assert result["caption_count"] == 5
video = VideoFileClip(str(final))
# 5x4s = 20s total (no crossfade)
assert video.duration == pytest.approx(20.0, abs=1.0)
assert video.size == [320, 180]
assert video.audio is not None
video.close()
def test_export_final_quality(self, video_clips, tmp_path):
"""Export with specific codec/bitrate and verify."""
stitched = tmp_path / "raw.mp4"
stitch_clips(
[str(p) for p in video_clips[:2]],
transition_duration=0,
output_path=str(stitched),
)
final = tmp_path / "hq.mp4"
result = export_final(
str(stitched),
output_path=str(final),
codec="libx264",
bitrate="5000k",
)
assert result["success"]
assert result["codec"] == "libx264"
assert final.stat().st_size > 5000
video = VideoFileClip(str(final))
# Two 4s clips = 8s
assert video.duration == pytest.approx(8.0, abs=1.0)
video.close()
# ── Creative Director pipeline (AI calls replaced with fixtures) ────────────
class TestCreativeDirectorPipeline:
"""Run the full director pipeline; only AI model inference is stubbed
with real-file fixture generators. All assembly runs for real."""
def _make_storyboard_stub(self, frames_dir):
"""Return a callable that produces real PNGs in tool-result format."""
def stub(descriptions):
frames = make_storyboard(
frames_dir, descriptions, width=320, height=180,
)
return {
"success": True,
"frame_count": len(frames),
"frames": [
{"path": str(f), "scene_index": i, "prompt": descriptions[i]}
for i, f in enumerate(frames)
],
}
return stub
def _make_song_stub(self, audio_dir):
"""Return a callable that produces a real WAV in tool-result format."""
def stub(lyrics="", genre="pop", duration=60, title=""):
path = make_audio_track(
audio_dir / "song.wav",
duration_seconds=min(duration, 25),
)
return {
"success": True,
"path": str(path),
"genre": genre,
"duration": min(duration, 25),
}
return stub
def _make_video_stub(self, clips_dir):
"""Return a callable that produces real MP4s in tool-result format."""
counter = [0]
def stub(image_path=None, prompt="scene", duration=4, **kwargs):
path = make_video_clip(
clips_dir / f"gen_{counter[0]:03d}.mp4",
duration_seconds=duration,
fps=12, width=320, height=180,
label=prompt[:20],
)
counter[0] += 1
return {
"success": True,
"path": str(path),
"duration": duration,
}
return stub
def test_full_pipeline_end_to_end(self, scene_defs, tmp_path):
"""run_full_pipeline with real fixtures at every stage."""
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
audio_dir = tmp_path / "audio"
audio_dir.mkdir()
clips_dir = tmp_path / "clips"
clips_dir.mkdir()
assembly_dir = tmp_path / "assembly"
assembly_dir.mkdir()
with (
patch("tools.image_tools.generate_storyboard",
side_effect=self._make_storyboard_stub(frames_dir)),
patch("tools.music_tools.generate_song",
side_effect=self._make_song_stub(audio_dir)),
patch("tools.video_tools.image_to_video",
side_effect=self._make_video_stub(clips_dir)),
patch("tools.video_tools.generate_video_clip",
side_effect=self._make_video_stub(clips_dir)),
patch("creative.director._project_dir",
return_value=tmp_path / "project"),
patch("creative.director._save_project"),
patch("creative.assembler._output_dir",
return_value=assembly_dir),
):
result = run_full_pipeline(
title="Integration Test Video",
description="End-to-end pipeline test",
scenes=scene_defs,
lyrics="Test lyrics for the song",
genre="rock",
)
assert result["success"], f"Pipeline failed: {result}"
assert result["project_id"]
assert result["final_video"] is not None
assert result["project"]["status"] == "complete"
assert result["project"]["has_final"] is True
assert result["project"]["clip_count"] == 5
# Inspect the final video
final_path = Path(result["final_video"]["path"])
assert final_path.exists()
assert final_path.stat().st_size > 5000
video = VideoFileClip(str(final_path))
# 5x4s clips - 4x1s crossfade = 16s body + 4s title card ~= 20s
assert video.duration >= 10.0
assert video.size == [320, 180]
assert video.audio is not None
video.close()
def test_step_by_step_pipeline(self, scene_defs, tmp_path):
"""Run each pipeline step individually — mirrors manual usage."""
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
audio_dir = tmp_path / "audio"
audio_dir.mkdir()
clips_dir = tmp_path / "clips"
clips_dir.mkdir()
assembly_dir = tmp_path / "assembly"
assembly_dir.mkdir()
# 1. Create project
with (
patch("creative.director._project_dir",
return_value=tmp_path / "proj"),
patch("creative.director._save_project"),
):
proj = create_project(
"Step-by-Step Video",
"Manual pipeline test",
scenes=scene_defs,
lyrics="Step by step, we build it all",
)
pid = proj["project"]["id"]
assert proj["success"]
# 2. Storyboard
with (
patch("tools.image_tools.generate_storyboard",
side_effect=self._make_storyboard_stub(frames_dir)),
patch("creative.director._save_project"),
):
sb = run_storyboard(pid)
assert sb["success"]
assert sb["frame_count"] == 5
# 3. Music
with (
patch("tools.music_tools.generate_song",
side_effect=self._make_song_stub(audio_dir)),
patch("creative.director._save_project"),
):
mus = run_music(pid, genre="electronic")
assert mus["success"]
assert mus["genre"] == "electronic"
# Verify the audio file exists and is valid
audio_path = Path(mus["path"])
assert audio_path.exists()
assert audio_path.stat().st_size > 1000
# 4. Video generation (uses storyboard frames → image_to_video)
with (
patch("tools.video_tools.image_to_video",
side_effect=self._make_video_stub(clips_dir)),
patch("creative.director._save_project"),
):
vid = run_video_generation(pid)
assert vid["success"]
assert vid["clip_count"] == 5
# Verify each clip exists
for clip_info in vid["clips"]:
clip_path = Path(clip_info["path"])
assert clip_path.exists()
assert clip_path.stat().st_size > 1000
# 5. Assembly (all real MoviePy operations)
with (
patch("creative.director._save_project"),
patch("creative.assembler._output_dir",
return_value=assembly_dir),
):
asm = run_assembly(pid, transition_duration=0.5)
assert asm["success"]
# Inspect final output
final_path = Path(asm["path"])
assert final_path.exists()
assert final_path.stat().st_size > 5000
video = VideoFileClip(str(final_path))
# 5x4s - 4x0.5s = 18s body, + title card ~= 22s
assert video.duration >= 10.0
assert video.size == [320, 180]
assert video.audio is not None
video.close()
# Verify project reached completion
project = _projects[pid]
assert project.status == "complete"
assert project.final_video is not None
assert len(project.video_clips) == 5
assert len(project.storyboard_frames) == 5
assert project.music_track is not None

View File

@@ -0,0 +1,93 @@
"""Tests for tools.video_tools — Video generation (Reel persona).
Heavy AI model tests are skipped; only catalogue, interface, and
resolution preset tests run in CI.
"""
import pytest
from unittest.mock import patch, MagicMock
from tools.video_tools import (
VIDEO_TOOL_CATALOG,
RESOLUTION_PRESETS,
VIDEO_STYLES,
list_video_styles,
generate_video_clip,
image_to_video,
)
class TestVideoToolCatalog:
def test_catalog_has_all_tools(self):
expected = {"generate_video_clip", "image_to_video", "list_video_styles"}
assert expected == set(VIDEO_TOOL_CATALOG.keys())
def test_catalog_entries_have_required_keys(self):
for tool_id, info in VIDEO_TOOL_CATALOG.items():
assert "name" in info
assert "description" in info
assert "fn" in info
assert callable(info["fn"])
class TestResolutionPresets:
def test_480p_preset(self):
assert RESOLUTION_PRESETS["480p"] == (854, 480)
def test_720p_preset(self):
assert RESOLUTION_PRESETS["720p"] == (1280, 720)
class TestVideoStyles:
def test_common_styles_present(self):
for style in ["cinematic", "anime", "documentary"]:
assert style in VIDEO_STYLES
class TestListVideoStyles:
def test_returns_styles_and_resolutions(self):
result = list_video_styles()
assert result["success"]
assert "cinematic" in result["styles"]
assert "480p" in result["resolutions"]
assert "720p" in result["resolutions"]
class TestGenerateVideoClipInterface:
def test_raises_without_creative_deps(self):
with patch("tools.video_tools._t2v_pipeline", None):
with patch("tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
with pytest.raises(ImportError):
generate_video_clip("a sunset")
def test_duration_clamped(self):
"""Duration is clamped to 210 range."""
import sys
mock_pipe = MagicMock()
mock_pipe.device = "cpu"
mock_result = MagicMock()
mock_result.frames = [[MagicMock() for _ in range(48)]]
mock_pipe.return_value = mock_result
mock_torch = MagicMock()
mock_torch.Generator.return_value = MagicMock()
out_dir = MagicMock()
out_dir.__truediv__ = MagicMock(return_value=MagicMock(__str__=lambda s: "/fake/clip.mp4"))
with patch.dict(sys.modules, {"torch": mock_torch}):
with patch("tools.video_tools._get_t2v_pipeline", return_value=mock_pipe):
with patch("tools.video_tools._export_frames_to_mp4"):
with patch("tools.video_tools._output_dir", return_value=out_dir):
with patch("tools.video_tools._save_metadata"):
result = generate_video_clip("test", duration=50)
assert result["duration"] == 10 # clamped
class TestImageToVideoInterface:
def test_raises_without_creative_deps(self):
with patch("tools.video_tools._t2v_pipeline", None):
with patch("tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
with pytest.raises(ImportError):
image_to_video("/fake/image.png", "animate")