"""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)