162 lines
6.4 KiB
Python
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)
|