forked from Rockachopa/Timmy-time-dashboard
This commit is contained in:
148
tests/unit/test_content_episode.py
Normal file
148
tests/unit/test_content_episode.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user