149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""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)
|