"""Unit tests for timmy_serve.voice_tts. Mocks pyttsx3 so tests run without audio hardware. """ import threading from unittest.mock import MagicMock, patch class TestVoiceTTSInit: """Test VoiceTTS initialization with/without pyttsx3.""" def test_init_success(self): """When pyttsx3 is available, engine initializes with given rate/volume.""" 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 assert tts._rate == 200 assert tts._volume == 0.8 mock_engine.setProperty.assert_any_call("rate", 200) mock_engine.setProperty.assert_any_call("volume", 0.8) def test_init_import_failure(self): """When pyttsx3 import fails, VoiceTTS degrades gracefully.""" with patch.dict("sys.modules", {"pyttsx3": None}): # Force reimport by clearing cache import sys modules_to_clear = [k for k in sys.modules.keys() if "voice_tts" in k] for mod in modules_to_clear: del sys.modules[mod] from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS() assert tts.available is False assert tts._engine is None class TestVoiceTTSSpeak: """Test VoiceTTS speak methods.""" def test_speak_skips_when_not_available(self): """speak() should skip gracefully when TTS is not available.""" 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 world") def test_speak_sync_skips_when_not_available(self): """speak_sync() should skip gracefully when TTS is not available.""" 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_sync("hello world") def test_speak_runs_in_background_thread(self): """speak() should run speech in a background thread.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._available = True tts._lock = threading.Lock() captured_threads = [] original_thread = threading.Thread def capture_thread(*args, **kwargs): t = original_thread(*args, **kwargs) captured_threads.append(t) return t with patch.object(threading, "Thread", side_effect=capture_thread): tts.speak("test message") # Wait for threads to complete for t in captured_threads: t.join(timeout=1) tts._engine.say.assert_called_with("test message") tts._engine.runAndWait.assert_called_once() class TestVoiceTTSProperties: """Test VoiceTTS property setters.""" def test_set_rate_updates_property(self): """set_rate() updates internal rate and engine property.""" 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_without_engine(self): """set_rate() updates internal rate even when engine is None.""" 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_to_max(self): """set_volume() clamps volume to maximum of 1.0.""" 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._engine.setProperty.assert_called_with("volume", 1.0) def test_set_volume_clamped_to_min(self): """set_volume() clamps volume to minimum of 0.0.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._volume = 0.9 tts.set_volume(-0.5) assert tts._volume == 0.0 tts._engine.setProperty.assert_called_with("volume", 0.0) def test_set_volume_within_range(self): """set_volume() accepts values within 0.0-1.0 range.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._volume = 0.9 tts.set_volume(0.5) assert tts._volume == 0.5 tts._engine.setProperty.assert_called_with("volume", 0.5) class TestVoiceTTSGetVoices: """Test VoiceTTS get_voices() method.""" def test_get_voices_returns_empty_list_when_no_engine(self): """get_voices() returns empty list when engine is None.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None result = tts.get_voices() assert result == [] def test_get_voices_returns_formatted_voice_list(self): """get_voices() returns list of voice dicts with id, name, languages.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) mock_voice1 = MagicMock() mock_voice1.id = "com.apple.voice.compact.en-US.Samantha" mock_voice1.name = "Samantha" mock_voice1.languages = ["en-US"] mock_voice2 = MagicMock() mock_voice2.id = "com.apple.voice.compact.en-GB.Daniel" mock_voice2.name = "Daniel" mock_voice2.languages = ["en-GB"] tts._engine = MagicMock() tts._engine.getProperty.return_value = [mock_voice1, mock_voice2] voices = tts.get_voices() assert len(voices) == 2 assert voices[0]["id"] == "com.apple.voice.compact.en-US.Samantha" assert voices[0]["name"] == "Samantha" assert voices[0]["languages"] == ["en-US"] assert voices[1]["id"] == "com.apple.voice.compact.en-GB.Daniel" assert voices[1]["name"] == "Daniel" assert voices[1]["languages"] == ["en-GB"] def test_get_voices_handles_missing_languages_attr(self): """get_voices() handles voices without languages attribute.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) mock_voice = MagicMock() mock_voice.id = "voice1" mock_voice.name = "Default Voice" # No languages attribute del mock_voice.languages tts._engine = MagicMock() tts._engine.getProperty.return_value = [mock_voice] voices = tts.get_voices() assert len(voices) == 1 assert voices[0]["languages"] == [] def test_get_voices_handles_exception(self): """get_voices() returns empty list on exception.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts._engine.getProperty.side_effect = RuntimeError("engine error") result = tts.get_voices() assert result == [] class TestVoiceTTSSetVoice: """Test VoiceTTS set_voice() method.""" def test_set_voice_updates_property(self): """set_voice() updates engine voice property when engine exists.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = MagicMock() tts.set_voice("com.apple.voice.compact.en-US.Samantha") tts._engine.setProperty.assert_called_with( "voice", "com.apple.voice.compact.en-US.Samantha" ) def test_set_voice_skips_when_no_engine(self): """set_voice() does nothing when engine is None.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._engine = None # Should not raise tts.set_voice("some_voice_id") class TestVoiceTTSAvailableProperty: """Test VoiceTTS available property.""" def test_available_returns_true_when_initialized(self): """available property returns True when engine initialized.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._available = True assert tts.available is True def test_available_returns_false_when_not_initialized(self): """available property returns False when engine not initialized.""" from timmy_serve.voice_tts import VoiceTTS tts = VoiceTTS.__new__(VoiceTTS) tts._available = False assert tts.available is False