Files
Timmy-time-dashboard/tests/unit/test_content_narrator.py
Claude (Opus 4.6) f0841bd34e
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[claude] Automated Episode Compiler — Highlights to Published Video (#880) (#1318)
2026-03-24 02:05:14 +00:00

162 lines
6.4 KiB
Python

"""Unit tests for content.narration.narrator."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from content.narration.narrator import (
NarrationResult,
_kokoro_available,
_piper_available,
build_episode_script,
generate_narration,
)
# ── _kokoro_available / _piper_available ──────────────────────────────────────
class TestBackendAvailability:
def test_kokoro_returns_bool(self):
assert isinstance(_kokoro_available(), bool)
def test_piper_returns_bool(self):
assert isinstance(_piper_available(), bool)
def test_kokoro_false_when_spec_missing(self):
with patch("importlib.util.find_spec", return_value=None):
assert _kokoro_available() is False
def test_piper_false_when_binary_missing(self):
with patch("content.narration.narrator.shutil.which", return_value=None):
assert _piper_available() is False
def test_piper_true_when_binary_found(self):
with patch("content.narration.narrator.shutil.which", return_value="/usr/bin/piper"):
assert _piper_available() is True
# ── generate_narration ────────────────────────────────────────────────────────
class TestGenerateNarration:
@pytest.mark.asyncio
async def test_empty_text_returns_failure(self, tmp_path):
result = await generate_narration("", str(tmp_path / "out.wav"))
assert result.success is False
assert "empty" in result.error.lower()
@pytest.mark.asyncio
async def test_whitespace_only_returns_failure(self, tmp_path):
result = await generate_narration(" \n\t ", str(tmp_path / "out.wav"))
assert result.success is False
@pytest.mark.asyncio
async def test_no_backend_returns_failure(self, tmp_path):
with (
patch("content.narration.narrator._kokoro_available", return_value=False),
patch("content.narration.narrator._piper_available", return_value=False),
):
result = await generate_narration("Hello world", str(tmp_path / "out.wav"))
assert result.success is False
assert "no TTS backend" in result.error
@pytest.mark.asyncio
async def test_kokoro_success(self, tmp_path):
async def _fake_kokoro(text, output_path):
return NarrationResult(success=True, audio_path=output_path, backend="kokoro")
with (
patch("content.narration.narrator._kokoro_available", return_value=True),
patch("content.narration.narrator._generate_kokoro", side_effect=_fake_kokoro),
):
result = await generate_narration("Test narration", str(tmp_path / "out.wav"))
assert result.success is True
assert result.backend == "kokoro"
@pytest.mark.asyncio
async def test_falls_back_to_piper_when_kokoro_fails(self, tmp_path):
async def _failing_kokoro(text, output_path):
return NarrationResult(success=False, backend="kokoro", error="kokoro error")
async def _ok_piper(text, output_path):
return NarrationResult(success=True, audio_path=output_path, backend="piper")
with (
patch("content.narration.narrator._kokoro_available", return_value=True),
patch("content.narration.narrator._piper_available", return_value=True),
patch("content.narration.narrator._generate_kokoro", side_effect=_failing_kokoro),
patch("content.narration.narrator._generate_piper", side_effect=_ok_piper),
):
result = await generate_narration("Test narration", str(tmp_path / "out.wav"))
assert result.success is True
assert result.backend == "piper"
@pytest.mark.asyncio
async def test_piper_called_when_kokoro_unavailable(self, tmp_path):
async def _ok_piper(text, output_path):
return NarrationResult(success=True, audio_path=output_path, backend="piper")
with (
patch("content.narration.narrator._kokoro_available", return_value=False),
patch("content.narration.narrator._piper_available", return_value=True),
patch("content.narration.narrator._generate_piper", side_effect=_ok_piper),
):
result = await generate_narration("Hello", str(tmp_path / "out.wav"))
assert result.success is True
assert result.backend == "piper"
# ── build_episode_script ──────────────────────────────────────────────────────
class TestBuildEpisodeScript:
def test_contains_title(self):
script = build_episode_script("Daily Highlights", [])
assert "Daily Highlights" in script
def test_contains_highlight_descriptions(self):
highlights = [
{"description": "Epic kill streak"},
{"description": "Clutch win"},
]
script = build_episode_script("Episode 1", highlights)
assert "Epic kill streak" in script
assert "Clutch win" in script
def test_highlight_numbering(self):
highlights = [{"description": "First"}, {"description": "Second"}]
script = build_episode_script("EP", highlights)
assert "Highlight 1" in script
assert "Highlight 2" in script
def test_uses_title_as_fallback_when_no_description(self):
highlights = [{"title": "Big Moment"}]
script = build_episode_script("EP", highlights)
assert "Big Moment" in script
def test_uses_index_as_fallback_when_no_title_or_description(self):
highlights = [{}]
script = build_episode_script("EP", highlights)
assert "Highlight 1" in script
def test_contains_default_outro(self):
script = build_episode_script("EP", [])
assert "subscribe" in script.lower()
def test_custom_outro_replaces_default(self):
script = build_episode_script("EP", [], outro_text="Custom outro text here.")
assert "Custom outro text here." in script
assert "subscribe" not in script.lower()
def test_empty_highlights_still_has_intro(self):
script = build_episode_script("My Show", [])
assert "Welcome to My Show" in script
def test_returns_string(self):
assert isinstance(build_episode_script("EP", []), str)