diff --git a/tests/timmy/test_voice_tts_unit.py b/tests/timmy/test_voice_tts_unit.py new file mode 100644 index 00000000..10d96559 --- /dev/null +++ b/tests/timmy/test_voice_tts_unit.py @@ -0,0 +1,280 @@ +"""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