"""Functional tests for timmy_serve.voice_tts — TTS engine lifecycle. pyttsx3 is not available in CI, so all tests mock the engine. """ import threading from unittest.mock import MagicMock, patch class TestVoiceTTS: """Test TTS engine initialization, speak, and configuration.""" def test_init_success(self): mock_pyttsx3 = MagicMock() mock_engine = MagicMock() mock_pyttsx3.init.return_value = mock_engine with patch.dict("sys.modules", {"pyttsx3": mock_pyttsx3}): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS(rate=200, volume=0.8) assert tts.available is True mock_engine.setProperty.assert_any_call("rate", 200) mock_engine.setProperty.assert_any_call("volume", 0.8) def test_init_failure_graceful(self): """When pyttsx3 import fails, VoiceTTS degrades gracefully.""" with patch.dict("sys.modules", {"pyttsx3": None}): import timmy_serve.voice_tts as mod tts = mod.VoiceTTS.__new__(mod.VoiceTTS) tts._engine = None tts._rate = 175 tts._volume = 0.9 tts._available = False tts._lock = threading.Lock() assert tts.available is False def test_speak_skips_when_unavailable(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None tts._available = False tts._lock = threading.Lock() # Should not raise tts.speak("hello") def test_speak_sync_skips_when_unavailable(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None tts._available = False tts._lock = threading.Lock() tts.speak_sync("hello") def test_speak_calls_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._available = True tts._lock = threading.Lock() # Patch threading.Thread to capture the thread and join it original_thread_class = threading.Thread captured_threads = [] def capture_thread(*args, **kwargs): t = original_thread_class(*args, **kwargs) captured_threads.append(t) return t with patch.object(threading, "Thread", side_effect=capture_thread): tts.speak("test speech") # Wait for the background thread to complete for t in captured_threads: t.join(timeout=1) tts._engine.say.assert_called_with("test speech") def test_speak_sync_calls_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._available = True tts._lock = threading.Lock() tts.speak_sync("sync test") tts._engine.say.assert_called_with("sync test") tts._engine.runAndWait.assert_called_once() def test_set_rate(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._rate = 175 tts.set_rate(220) assert tts._rate == 220 tts._engine.setProperty.assert_called_with("rate", 220) def test_set_rate_no_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None tts._rate = 175 tts.set_rate(220) assert tts._rate == 220 def test_set_volume_clamped(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._volume = 0.9 tts.set_volume(1.5) assert tts._volume == 1.0 tts.set_volume(-0.5) assert tts._volume == 0.0 tts.set_volume(0.7) assert tts._volume == 0.7 def test_get_voices_no_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None assert tts.get_voices() == [] def test_get_voices_with_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) mock_voice = MagicMock() mock_voice.id = "voice1" mock_voice.name = "Default" mock_voice.languages = ["en"] tts._engine = MagicMock() tts._engine.getProperty.return_value = [mock_voice] voices = tts.get_voices() assert len(voices) == 1 assert voices[0]["id"] == "voice1" assert voices[0]["name"] == "Default" assert voices[0]["languages"] == ["en"] def test_get_voices_exception(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._engine.getProperty.side_effect = RuntimeError("no voices") assert tts.get_voices() == [] def test_set_voice(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts.set_voice("voice_id_1") tts._engine.setProperty.assert_called_with("voice", "voice_id_1") def test_set_voice_no_engine(self): from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None tts.set_voice("voice_id_1") # should not raise