"""Unit tests for content.composition.episode.""" from __future__ import annotations from unittest.mock import patch import pytest from content.composition.episode import ( EpisodeResult, EpisodeSpec, _moviepy_available, _slugify, build_episode, ) # ── _slugify ────────────────────────────────────────────────────────────────── class TestSlugify: def test_basic(self): assert _slugify("Hello World") == "hello-world" def test_special_chars_removed(self): assert _slugify("Top Highlights — March 2026") == "top-highlights--march-2026" def test_truncates_long_strings(self): long = "a" * 100 assert len(_slugify(long)) <= 80 def test_empty_string_returns_episode(self): assert _slugify("") == "episode" def test_no_leading_or_trailing_dashes(self): result = _slugify(" hello ") assert not result.startswith("-") assert not result.endswith("-") # ── EpisodeSpec ─────────────────────────────────────────────────────────────── class TestEpisodeSpec: def test_default_transition_from_settings(self): spec = EpisodeSpec(title="EP") from config import settings assert spec.resolved_transition == settings.video_transition_duration def test_custom_transition_overrides_settings(self): spec = EpisodeSpec(title="EP", transition_duration=2.5) assert spec.resolved_transition == pytest.approx(2.5) def test_resolved_output_contains_slug(self): spec = EpisodeSpec(title="My Episode") assert "my-episode" in spec.resolved_output def test_explicit_output_path_preserved(self): spec = EpisodeSpec(title="EP", output_path="/tmp/custom.mp4") assert spec.resolved_output == "/tmp/custom.mp4" # ── _moviepy_available ──────────────────────────────────────────────────────── class TestMoviepyAvailable: def test_returns_bool(self): assert isinstance(_moviepy_available(), bool) def test_false_when_spec_missing(self): with patch("importlib.util.find_spec", return_value=None): assert _moviepy_available() is False # ── build_episode ───────────────────────────────────────────────────────────── class TestBuildEpisode: @pytest.mark.asyncio async def test_returns_failure_when_moviepy_missing(self): with patch("content.composition.episode._moviepy_available", return_value=False): result = await build_episode( clip_paths=[], title="Test Episode", ) assert result.success is False assert "moviepy" in result.error.lower() @pytest.mark.asyncio async def test_returns_failure_when_compose_raises(self): with ( patch("content.composition.episode._moviepy_available", return_value=True), patch( "content.composition.episode._compose_sync", side_effect=RuntimeError("compose error"), ), ): result = await build_episode( clip_paths=[], title="Test Episode", ) assert result.success is False assert "compose error" in result.error @pytest.mark.asyncio async def test_returns_episode_result_on_success(self): fake_result = EpisodeResult( success=True, output_path="/tmp/ep.mp4", duration=42.0, clip_count=3, ) with ( patch("content.composition.episode._moviepy_available", return_value=True), patch( "asyncio.to_thread", return_value=fake_result, ), ): result = await build_episode( clip_paths=["/tmp/a.mp4"], title="Test Episode", output_path="/tmp/ep.mp4", ) assert result.success is True assert result.output_path == "/tmp/ep.mp4" assert result.duration == pytest.approx(42.0) assert result.clip_count == 3 @pytest.mark.asyncio async def test_spec_receives_custom_transition(self): captured_spec = {} def _capture_compose(spec): captured_spec["spec"] = spec return EpisodeResult(success=True, output_path="/tmp/ep.mp4") with ( patch("content.composition.episode._moviepy_available", return_value=True), patch("asyncio.to_thread", side_effect=lambda fn, spec: _capture_compose(spec)), ): await build_episode( clip_paths=[], title="EP", transition_duration=3.0, ) assert captured_spec["spec"].resolved_transition == pytest.approx(3.0)