Files
Timmy-time-dashboard/tests/timmy/test_voice_tts_unit.py
Kimi Agent 28b8673584
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[kimi] Add unit tests for voice_tts.py (#768) (#829)
2026-03-21 21:56:45 +00:00

281 lines
8.9 KiB
Python

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